在做智驾标注工具时踩了个坑:为什么我写的 Shader 贴图总是比原图暗?深入探究颜色空间的奥秘!
📌 问题引入:贴图怎么变暗了?
最近在开发一个基于 Three.js 的 3D 标注工具,需要自定义着色器来实现一些特殊的贴图效果。写了个最基础的贴图材质:
glsl
varying vec2 vUv;
uniform sampler2D texture;
void main() {
vec4 texColor = texture2D(texture, vUv);
gl_FragColor = texColor;
}
结果傻眼了:渲染出来的贴图比原图明显偏暗!
这不对啊!我明明就是直接采样贴图,怎么就变暗了?难道是纹理加载的问题?还是我的光照设置有问题?
🔍 问题根源:颜色空间的"暗箱操作"
经过一番排查,终于找到了罪魁祸首:颜色空间(Color Space)自动转换。
🎯 Three.js 的默认行为
Three.js 为了保证物理正确的光照计算,默认会对纹理做这样的处理:
typescript
const texture = new THREE.TextureLoader().load('image.jpg');
// Three.js 自动设置:
texture.colorSpace = THREE.SRGBColorSpace; // r152+ 旧版用 encoding
这意味着:
- 图片存储格式:JPG/PNG 是 sRGB 编码(gamma ≈ 2.2)
- Three.js 自动转换:采样时自动将 sRGB → Linear
- Shader 中拿到的:已经是线性空间的颜色值
📐 什么是 sRGB 和 Linear?
| 颜色空间 | 说明 | 特点 |
|---|---|---|
| sRGB | 人眼感知的颜色空间 | 非线性,暗部压缩,亮部展开 |
| Linear | 物理光强的线性空间 | 线性,适合光照计算 |
关键点:人眼对亮度的感知是非线性的(史蒂文斯幂定律),而物理光照计算需要在线性空间进行。
🔄 颜色空间转换公式
glsl
// sRGB → Linear (解码)
vec3 linear = pow(srgb.rgb, vec3(2.2));
// Linear → sRGB (编码)
vec3 srgb = pow(linear.rgb, vec3(1.0/2.2));
💡 为什么贴图会偏暗?
场景还原
typescript
// Three.js 内部做了这件事:
vec4 sampled = texture2D(texture, vUv); // 返回的是 Linear 空间颜色
// 然后我们直接输出:
gl_FragColor = sampled; // ❌ 问题:Linear 颜色直接输出到 sRGB 帧缓冲
结果:线性空间的颜色值(如 0.5)直接显示在屏幕上,人眼感知会比预期的暗很多。
🧪 数值对比
| 原图 sRGB | Linear 空间 | 直接输出到屏幕(错误) | 正确输出到屏幕 |
|---|---|---|---|
| 0.5 (中灰) | 0.217 | 显示为 0.217 (很暗) | 应显示为 0.5 |
| 0.8 (亮灰) | 0.578 | 显示为 0.578 (偏暗) | 应显示为 0.8 |
看到了吗? 0.5 的中灰色在 Linear 空间只有 0.217,直接输出就变成了"深灰"!
✅ 解决方案
手动 Gamma 编码(推荐)
保留 Three.js 的自动转换,在 Shader 中手动做 Gamma 编码:
glsl
uniform sampler2D texture;
varying vec2 vUv;
void main() {
vec4 color = texture2D(texture, vUv); // Linear 空间
color.rgb = pow(color.rgb, vec3(1.0/2.2)); // Linear → sRGB
gl_FragColor = color;
}
或者封装成函数更清晰:
glsl
vec3 linearToSRGB(vec3 linear) {
return pow(linear, vec3(1.0/2.2));
}
void main() {
vec4 color = texture2D(texture, vUv);
color.rgb = linearToSRGB(color.rgb);
gl_FragColor = color;
}
优点 :符合图形学最佳实践,后续加光照也方便
缺点:需要理解颜色空间概念
方案 3:使用内置工具函数(Three.js r152+)
Three.js 提供了内置的颜色空间转换函数:
glsl
#include <color_space_pars_fragment>
uniform sampler2D texture;
varying vec2 vUv;
void main() {
vec4 color = texture2D(texture, vUv);
color = SRGBToLinear(color); // 如果需要在线性空间计算
// ... 光照计算 ...
color = LinearToSRGB(color); // 最后转回 sRGB
gl_FragColor = color;
}
🎯 实战:完整的贴图材质
typescript
import * as THREE from 'three';
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform sampler2D texture;
varying vec2 vUv;
// Linear → sRGB
vec3 linearToSRGB(vec3 linear) {
return pow(linear, vec3(1.0/2.2));
}
void main() {
vec4 color = texture2D(texture, vUv); // Three.js 已自动转为 Linear
color.rgb = linearToSRGB(color.rgb); // 手动转回 sRGB
gl_FragColor = color;
}
`;
const texture = new THREE.TextureLoader().load('image.jpg');
// texture.colorSpace = THREE.SRGBColorSpace; // 默认就是这个,不用设置
const material = new THREE.ShaderMaterial({
uniforms: {
texture: { value: texture }
},
vertexShader,
fragmentShader,
transparent: true
});
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
material
);
scene.add(plane);
🤔 为什么不用乘法,而用幂函数?
这是个好问题!为什么 Gamma 矫正要用 pow(x, 1/2.2) 而不是简单的 x * 0.5?
人眼感知的非线性
人眼对亮度的感知符合史蒂文斯幂定律:
scss
主观亮度 ∝ (物理光强)^0.4~0.5
这意味着:
- 物理光强增加 4 倍,人眼只感觉"亮了约 2 倍"
- 暗部的变化人眼更敏感,亮部的变化相对迟钝
线性乘法的灾难
glsl
// ❌ 线性乘法:所有亮度等比例压缩
vec3 dark = color * 0.5;
// ✅ Gamma:非线性压缩,匹配人眼感知
vec3 gamma = pow(color, vec3(1.0/2.2));
| 操作 | 暗部 (0.1) | 中灰 (0.5) | 亮部 (0.9) | 人眼感知 |
|---|---|---|---|---|
×0.5 |
0.05 | 0.25 | 0.45 | 暗部细节丢失严重 ❌ |
^0.45 |
0.28 | 0.71 | 0.95 | 均匀压缩感知亮度 ✅ |
结论:幂函数是对人眼生物特性的数学拟合,不是随便选的!
📊 方案对比总结
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 手动 Gamma 编码 | 通用推荐 | 符合图形学规范,灵活 | 需要理解概念 |
| 内置工具函数 | Three.js r152+ | 官方支持,代码简洁 | 版本限制 |
💡 实用建议
1. 调试技巧
在 Shader 中临时验证:
glsl
// 输出纯中灰(应该看起来是 50% 灰)
gl_FragColor = vec4(0.5, 0.5, 0.5, 1.0); // ❌ 线性 0.5 会很暗
// 正确的 50% 视觉灰
gl_FragColor = vec4(0.73, 0.73, 0.73, 1.0); // ✅ ≈ pow(0.5, 1.0/2.2)
2. 标注工具场景
如果你在做标注工具,只是显示贴图:
typescript
// 推荐:禁用自动转换,简单高效
texture.colorSpace = THREE.NoColorSpace;
3. 可视化场景
如果需要光照、阴影等效果:
glsl
// 推荐:手动 Gamma 编码
vec4 color = texture2D(texture, vUv);
color.rgb = pow(color.rgb, vec3(1.0/2.2));
🎓 总结
贴图偏暗的问题,本质是颜色空间转换的理解问题:
- Three.js 默认:sRGB → Linear(自动)
- 自定义 Shader:需要手动将 Linear → sRGB
- Gamma 矫正 :用幂函数
pow(x, 1/2.2)而不是乘法,因为人眼感知是非线性的
理解了这个原理,以后写自定义材质就不会再踩坑了!
📚 延伸阅读
💬 互动时间:你在 Three.js 开发中还遇到过哪些"坑"?欢迎在评论区分享!
👍 如果觉得有用,记得点赞收藏,关注我获取更多图形学干货!
本文作者:红波 | 专注 WebGL/Three.js/可视化开发 | 智驾标注工具开发者