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

在做智驾标注工具时踩了个坑:为什么我写的 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

这意味着:

  1. 图片存储格式:JPG/PNG 是 sRGB 编码(gamma ≈ 2.2)
  2. Three.js 自动转换:采样时自动将 sRGB → Linear
  3. 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));

🎓 总结

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

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

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


📚 延伸阅读


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

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


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

相关推荐
云上空14 小时前
腾讯云使用对象存储托管并分享WebGL小游戏(unity3d)(需要域名)
unity·腾讯云·webgl·游戏开发·对象存储·网页托管
全栈王校长15 小时前
WebGL三角形绘制:掌握缓冲区与基本图元
前端·webgl
不会码码1 天前
WebGL+Three.js:打造网页3D模型展厅
javascript·3d·webgl
全栈王校长1 天前
WebGL 从零开始:绘制你的第一个3D点
前端·webgl
全栈王校长1 天前
WebGL 基础API详解:掌握3D图形编程的核心工具
前端·webgl
全栈王校长2 天前
WebGL 初探:让你的网页拥有3D魔法
前端·webgl
GISer_Jing3 天前
前端内容创作Agent提示词
前端·aigc·ai编程·webgl
简cere4 天前
解决webgl画面虚问题
webgl
Var_al5 天前
抖小Unity WebGL分包命令行工具实践指南
unity·游戏引擎·webgl