跳到主要内容
计算机图形学 约 5 分钟

实时渲染中的色彩空间:sRGB 与线性工作流

解释为什么光照计算必须在线性空间进行、sRGB 传输函数的精确形式与 2.2 近似的误差、Unity 与 three.js 各自在哪一步做转换,以及移植 URP 着色器到 WebGL 时最容易踩的双重转换问题。

本文目录

一个反复出现的 bug

把 Unity 里的着色器搬到 WebGL,颜色对了、光照公式也逐行对照过,画面却明显偏暗或者发灰。九成情况下问题不在光照,而在色彩空间:某一处多做了一次或少做了一次 sRGB 编解码。

为什么光照必须在线性空间

物理上,光强是可加的:两盏一样亮的灯照在同一块表面上,辐照度是两倍。显示设备的输入信号却不是——sRGB 是一条非线性的传输曲线,用来在 8 bit 里塞进更多人眼敏感的暗部层次。

如果在 sRGB 编码后的数值上做加法或乘法,结果就不是物理正确的:

srgb⁡(a)+srgb⁡(b)≠srgb⁡(a+b)\operatorname{srgb}(a) + \operatorname{srgb}(b) \neq \operatorname{srgb}(a + b)

最直观的反例是混合:把黑 (0) 和白 (255) 各取一半,在 sRGB 数值上得到 127.5,显示出来是一个明显偏亮的灰;而在线性空间混合,得到的是感知上「一半亮」的中灰,对应 sRGB 约 188。纹理过滤、mip 生成、顶点插值同理——所有这些操作都假设数值是线性的。

所以标准流程是:输入解码到线性 → 全部计算在线性 → 输出编码回 sRGB。

sRGB 传输函数

线性 L∈[0,1]L \in [0,1] 到 sRGB 编码值 SS:

S={12.92 L,L≤0.00313081.055 L1/2.4−0.055,L>0.0031308S = \begin{cases} 12.92\,L, & L \le 0.0031308 \\[4pt] 1.055\,L^{1/2.4} - 0.055, & L > 0.0031308 \end{cases}

反向解码:

L={S/12.92,S≤0.04045(S+0.0551.055)2.4,S>0.04045L = \begin{cases} S / 12.92, & S \le 0.04045 \\[4pt] \left(\dfrac{S + 0.055}{1.055}\right)^{2.4}, & S > 0.04045 \end{cases}

低段那条直线不是随手加的:它保证函数在 L=0.0031308L = 0.0031308 处一阶连续(斜率相等),否则暗部会出现可见的折痕。

工程上常用 γ=2.2\gamma = 2.2 的纯幂函数近似:

S≈L1/2.2,L≈S2.2S \approx L^{1/2.2},\qquad L \approx S^{2.2}

在中段误差很小(最大约 0.5%~1%),但在极暗部误差迅速放大,而且纯幂函数在 0 附近导数无穷大,做纹理过滤时会出现暗边。生产代码里建议用精确的分段形式,成本只是多一次 step 和 mix:

vec3 linearToSRGB(vec3 c) {
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}

vec3 sRGBToLinear(vec3 s) {
  vec3 lo = s / 12.92;
  vec3 hi = pow(max(s + 0.055, vec3(0.0)) / 1.055, vec3(2.4));
  return mix(lo, hi, step(vec3(0.04045), s));
}

哪些数据是颜色,哪些不是

这是最容易搞错的一类问题。贴图里存的可能是颜色,也可能是纯数据:

贴图是否 sRGB原因
Albedo / BaseColor是美术在 sRGB 显示器上取的色
Emissive是同上
Normal map否xyz 是方向,编码在 [0,1] 里的线性数据
Roughness / Metallic / AO否物理参数,线性
Height / Displacement否线性数据
Mask(通道打包)否线性数据

把法线贴图当成 sRGB 导入,会得到「凹凸方向被扭曲」的结果,高光位置整体偏移;把 albedo 当成线性导入,颜色会明显过饱和。

在 three.js 里对应的是 texture.colorSpace:

albedoMap.colorSpace = THREE.SRGBColorSpace;   // 采样时自动解码
normalMap.colorSpace = THREE.NoColorSpace;     // 原样返回

在 Unity 里对应贴图导入设置的 sRGB (Color Texture) 勾选框,以及 GraphicsSettings 里的 Color Space = Linear。

Unity 在哪几步做转换

项目设为 Linear Color Space 时:

  1. 贴图采样:标记为 sRGB 的贴图在采样时由硬件自动解码为线性(GPU 支持 GL_SRGB8_ALPHA8 这类内部格式);
  2. 材质颜色属性:调色板上给的 sRGB 值,在传给着色器之前被转换成线性。所以着色器里读到的 _BaseColor 已经是线性的;
  3. 光照计算:全程线性;
  4. 输出:渲染目标是 sRGB framebuffer,硬件在写入时自动把线性值编码回 sRGB。着色器代码里看不到这一步。

第 2 点和第 4 点是最容易在移植时漏掉的,因为它们对 HLSL 源码完全透明。

three.js 在哪几步做转换

three.js 从 r152 起默认启用 ColorManagement:

  1. THREE.Color:new THREE.Color('#ff0000')、setStyle(str)、setHex(hex) 默认把输入当作 sRGB,并立即转换成工作色彩空间(linear-sRGB)。所以 color.r/g/b 读到的是线性分量,不是 1/0/0。
  2. 纹理:需要显式设置 texture.colorSpace = THREE.SRGBColorSpace,three 不会猜。
  3. 输出:renderer.outputColorSpace 默认是 THREE.SRGBColorSpace。但转换发生在着色器内部——three 给自家材质追加 #include <colorspace_fragment>,由那段代码完成编码。

第 3 点是关键:转换在着色器里而不是在 framebuffer 层。这带来一个直接后果——

RawShaderMaterial 不会得到任何输出转换代码。

three 的 WebGLProgram 在 parameters.isRawShaderMaterial 为真时,前缀里只剩自定义 define,#version 300 es 之后就是你的原始源码。所以用 RawShaderMaterial 写原生 GLSL ES 3.00 时,必须自己在片元着色器末尾调用 linearToSRGB(),否则画面会明显偏暗(因为把线性值直接当 sRGB 显示了)。

同时应该把 renderer.outputColorSpace 设成 THREE.LinearSRGBColorSpace,明确表达「转换已经在着色器里做完了」,避免以后换成 ShaderMaterial 时被再编码一次。

移植时的检查清单

以本站的 URP RimLit 移植示例 为例,逐项对照:

环节Unity URP浏览器移植
颜色属性输入引擎自动 sRGB → linearTHREE.Color.setStyle(hex, SRGBColorSpace)
光照计算线性线性(一致)
输出编码sRGB framebuffer,硬件完成片元着色器里显式 linearToSRGB()
renderer.outputColorSpace不适用设为 LinearSRGBColorSpace

判断有没有做错的快速方法:

  • 整体偏暗、暗部细节丢失 → 少了一次 linear → sRGB 编码;
  • 整体发灰、对比度不足、颜色像被洗过 → 多了一次编码(双重转换);
  • 只有贴图颜色不对,纯色材质正常 → 贴图的 colorSpace 没设;
  • 法线贴图效果诡异、高光位置漂移 → 法线贴图被当成 sRGB 解码了。

Canvas 与合成层

最后一点容易被忽略:WebGL 画布最终会被浏览器合成到页面上。默认情况下 canvas 的内容被当作 sRGB,和 CSS 颜色处在同一空间,所以只要着色器输出了正确的 sRGB 值,页面配色和画布就能自然统一。如果启用了 drawingBufferColorSpace = 'display-p3' 之类的宽色域输出,则整套数值含义都会改变,需要额外做色域转换——在没有明确需求前不建议开启。