🎨 Three.js 自定义材质贴图偏暗?一文搞懂颜色空间与手动 Gamma 矫正

23 阅读5分钟

在做智驾标注工具时踩了个坑:为什么我写的 Shader 贴图总是比原图暗?深入探究颜色空间的奥秘!


📌 问题引入:贴图怎么变暗了?

最近在开发一个基于 Three.js 的 3D 标注工具,需要自定义着色器来实现一些特殊的贴图效果。写了个最基础的贴图材质:

varying vec2 vUv;
uniform sampler2D texture;

void main() {
  vec4 texColor = texture2D(texture, vUv);
  gl_FragColor = texColor;
}

结果傻眼了:渲染出来的贴图比原图明显偏暗!

这不对啊!我明明就是直接采样贴图,怎么就变暗了?难道是纹理加载的问题?还是我的光照设置有问题?


🔍 问题根源:颜色空间的"暗箱操作"

经过一番排查,终于找到了罪魁祸首:颜色空间(Color Space)自动转换

🎯 Three.js 的默认行为

Three.js 为了保证物理正确的光照计算,默认会对纹理做这样的处理:

const texture = new THREE.TextureLoader().load('image.jpg');
// Three.js 自动设置:
texture.colorSpace = THREE.SRGBColorSpace; // r152+ 旧版用 encoding

这意味着:

  1. 图片存储格式:JPG/PNG 是 sRGB 编码(gamma ≈ 2.2)
  2. Three.js 自动转换:采样时自动将 sRGB → Linear
  3. Shader 中拿到的:已经是线性空间的颜色值

📐 什么是 sRGB 和 Linear?

颜色空间说明特点
sRGB人眼感知的颜色空间非线性,暗部压缩,亮部展开
Linear物理光强的线性空间线性,适合光照计算

关键点:人眼对亮度的感知是非线性的(史蒂文斯幂定律),而物理光照计算需要在线性空间进行。

🔄 颜色空间转换公式

// sRGB → Linear (解码)
vec3 linear = pow(srgb.rgb, vec3(2.2));

// Linear → sRGB (编码)
vec3 srgb = pow(linear.rgb, vec3(1.0/2.2));

💡 为什么贴图会偏暗?

场景还原

// Three.js 内部做了这件事:
vec4 sampled = texture2D(texture, vUv);  // 返回的是 Linear 空间颜色

// 然后我们直接输出:
gl_FragColor = sampled;  // ❌ 问题:Linear 颜色直接输出到 sRGB 帧缓冲

结果:线性空间的颜色值(如 0.5)直接显示在屏幕上,人眼感知会比预期的暗很多。

🧪 数值对比

原图 sRGBLinear 空间直接输出到屏幕(错误)正确输出到屏幕
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 编码:

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;
}

或者封装成函数更清晰:

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 提供了内置的颜色空间转换函数:

#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;
}

🎯 实战:完整的贴图材质

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

人眼感知的非线性

人眼对亮度的感知符合史蒂文斯幂定律

主观亮度 ∝ (物理光强)^0.4~0.5

这意味着:

  • 物理光强增加 4 倍,人眼只感觉"亮了约 2 倍"
  • 暗部的变化人眼更敏感,亮部的变化相对迟钝

线性乘法的灾难

// ❌ 线性乘法:所有亮度等比例压缩
vec3 dark = color * 0.5;

// ✅ Gamma:非线性压缩,匹配人眼感知
vec3 gamma = pow(color, vec3(1.0/2.2));
操作暗部 (0.1)中灰 (0.5)亮部 (0.9)人眼感知
×0.50.050.250.45暗部细节丢失严重 ❌
^0.450.280.710.95均匀压缩感知亮度 ✅

结论:幂函数是对人眼生物特性的数学拟合,不是随便选的!


📊 方案对比总结

方案适用场景优点缺点
手动 Gamma 编码通用推荐符合图形学规范,灵活需要理解概念
内置工具函数Three.js r152+官方支持,代码简洁版本限制

💡 实用建议

1. 调试技巧

在 Shader 中临时验证:

// 输出纯中灰(应该看起来是 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. 标注工具场景

如果你在做标注工具,只是显示贴图:

// 推荐:禁用自动转换,简单高效
texture.colorSpace = THREE.NoColorSpace;

3. 可视化场景

如果需要光照、阴影等效果:

// 推荐:手动 Gamma 编码
vec4 color = texture2D(texture, vUv);
color.rgb = pow(color.rgb, vec3(1.0/2.2));

🎓 总结

贴图偏暗的问题,本质是颜色空间转换的理解问题:

  1. Three.js 默认:sRGB → Linear(自动)
  2. 自定义 Shader:需要手动将 Linear → sRGB
  3. Gamma 矫正:用幂函数 pow(x, 1/2.2) 而不是乘法,因为人眼感知是非线性的

理解了这个原理,以后写自定义材质就不会再踩坑了!


📚 延伸阅读


💬 互动时间:你在 Three.js 开发中还遇到过哪些"坑"?欢迎在评论区分享!

👍 如果觉得有用,记得点赞收藏,关注我获取更多图形学干货!


本文作者:红波 | 专注 WebGL/Three.js/可视化开发 | 智驾标注工具开发者