实时渲染中的色彩空间:sRGB 与线性工作流
解释为什么光照计算必须在线性空间进行、sRGB 传输函数的精确形式与 2.2 近似的误差、Unity 与 three.js 各自在哪一步做转换,以及移植 URP 着色器到 WebGL 时最容易踩的双重转换问题。
本文目录
一个反复出现的 bug
把 Unity 里的着色器搬到 WebGL,颜色对了、光照公式也逐行对照过,画面却明显偏暗或者发灰。九成情况下问题不在光照,而在色彩空间:某一处多做了一次或少做了一次 sRGB 编解码。
为什么光照必须在线性空间
物理上,光强是可加的:两盏一样亮的灯照在同一块表面上,辐照度是两倍。显示设备的输入信号却不是——sRGB 是一条非线性的传输曲线,用来在 8 bit 里塞进更多人眼敏感的暗部层次。
如果在 sRGB 编码后的数值上做加法或乘法,结果就不是物理正确的:
最直观的反例是混合:把黑 (0) 和白 (255) 各取一半,在 sRGB 数值上得到 127.5,显示出来是一个明显偏亮的灰;而在线性空间混合,得到的是感知上「一半亮」的中灰,对应 sRGB 约 188。纹理过滤、mip 生成、顶点插值同理——所有这些操作都假设数值是线性的。
所以标准流程是:输入解码到线性 → 全部计算在线性 → 输出编码回 sRGB。
sRGB 传输函数
线性 到 sRGB 编码值 :
反向解码:
低段那条直线不是随手加的:它保证函数在 处一阶连续(斜率相等),否则暗部会出现可见的折痕。
工程上常用 的纯幂函数近似:
在中段误差很小(最大约 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 时:
- 贴图采样:标记为 sRGB 的贴图在采样时由硬件自动解码为线性(GPU 支持
GL_SRGB8_ALPHA8这类内部格式); - 材质颜色属性:调色板上给的 sRGB 值,在传给着色器之前被转换成线性。所以着色器里读到的
_BaseColor已经是线性的; - 光照计算:全程线性;
- 输出:渲染目标是 sRGB framebuffer,硬件在写入时自动把线性值编码回 sRGB。着色器代码里看不到这一步。
第 2 点和第 4 点是最容易在移植时漏掉的,因为它们对 HLSL 源码完全透明。
three.js 在哪几步做转换
three.js 从 r152 起默认启用 ColorManagement:
THREE.Color:new THREE.Color('#ff0000')、setStyle(str)、setHex(hex)默认把输入当作 sRGB,并立即转换成工作色彩空间(linear-sRGB)。所以color.r/g/b读到的是线性分量,不是 1/0/0。- 纹理:需要显式设置
texture.colorSpace = THREE.SRGBColorSpace,three 不会猜。 - 输出:
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 → linear | THREE.Color.setStyle(hex, SRGBColorSpace) |
| 光照计算 | 线性 | 线性(一致) |
| 输出编码 | sRGB framebuffer,硬件完成 | 片元着色器里显式 linearToSRGB() |
renderer.outputColorSpace | 不适用 | 设为 LinearSRGBColorSpace |
判断有没有做错的快速方法:
- 整体偏暗、暗部细节丢失 → 少了一次 linear → sRGB 编码;
- 整体发灰、对比度不足、颜色像被洗过 → 多了一次编码(双重转换);
- 只有贴图颜色不对,纯色材质正常 → 贴图的
colorSpace没设; - 法线贴图效果诡异、高光位置漂移 → 法线贴图被当成 sRGB 解码了。
Canvas 与合成层
最后一点容易被忽略:WebGL 画布最终会被浏览器合成到页面上。默认情况下 canvas 的内容被当作 sRGB,和 CSS 颜色处在同一空间,所以只要着色器输出了正确的 sRGB 值,页面配色和画布就能自然统一。如果启用了 drawingBufferColorSpace = 'display-p3' 之类的宽色域输出,则整套数值含义都会改变,需要额外做色域转换——在没有明确需求前不建议开启。