渲染问题表面看是材质参数不对,实际是颜色空间和曝光链路出了岔子。同一张基础色贴图,在 A 引擎里正常,导入 B 引擎后却发灰或过曝,通常不是贴图质量下降,而是采样、光照计算和最终输出之间没有正确区分线性数据与显示编码。要解决材质过亮或过暗,需要把曝光放在光照阶段,把伽马校正放在输出阶段,这两步一旦错位,后续所有调节都会变成打补丁。

一、线性空间与sRGB空间为什么不能混用
显示器的亮度响应并不是线性的。输入电压翻倍,人眼感觉到的亮度并不会真实翻倍,因此显示设备普遍采用近似伽马2.2的曲线来编码颜色。sRGB就是最常见的非线性颜色空间,它的暗部用线性段来降低低亮度噪声,中高段用伽马曲线压缩。换句话说,一张sRGB贴图里存储的亮度值是已经做过显示编码的数值,它不适合直接拿来参与物理光照计算。
光照计算基于真实的能量关系。两束光叠加,亮度应该是两个线性值相加;粗糙度、法线、菲涅耳等模型也都假设输入颜色处于线性空间。如果你把sRGB空间的0.5当作线性值0.5去计算漫反射,实际能量只有线性空间约0.214,材质当然会偏暗。反之,把线性计算结果直接写入帧缓冲,又在显示端再被显示器做一次伽马解码,中间就会出现过曝或发灰。解决思路很明确:贴图采样后先解码到线性空间,光照完成后统一做伽马编码输出。
可以用一段最简单的GLSL说明这两次转换。假设现代图形API已经支持sRGB纹理格式,采样时硬件会自动解码,但如果你用的是普通格式,就需要手动处理。
// 手动将sRGB颜色解码为线性空间
vec3 srgbToLinear(vec3 srgb) {
return pow(srgb, vec3(2.2));
}
// 光照完成后,将线性颜色编码回sRGB用于显示
vec3 linearToSrgb(vec3 linear) {
return pow(linear, vec3(1.0 / 2.2));
}
void main() {
vec3 albedoSrgb = texture(albedoTex, uv).rgb;
vec3 albedoLinear = srgbToLinear(albedoSrgb);
// 在albedoLinear基础上进行光照计算
vec3 litColor = calculateLighting(albedoLinear, normal, viewDir);
vec3 outputColor = linearToSrgb(litColor);
fragColor = vec4(outputColor, 1.0);
}现代引擎通常会提供sRGB纹理导入选项,只有法线贴图、粗糙度贴图等数据贴图需要保持线性,因为它们存储的是逐像素向量或标量,不是颜色。如果把法线贴图也按sRGB解码,会破坏法线方向,引发奇怪的明暗变化。区分颜色贴图和数据贴图,是排查材质亮度的第一步。
二、曝光控制应该放在光照还是输出阶段
曝光是对场景整体亮度的缩放。它应当作用在线性HDR颜色上,而不是已经经过伽马编码的LDR颜色上。因为曝光本身是一个线性乘法操作,若放在sRGB空间做,就会产生非物理的亮度偏移:暗部被压得更死,高光被拉得过头。正确流程是光照计算得到HDR线性颜色后,先乘曝光系数,再做色调映射,最后进行伽马编码。
很多开发者习惯在材质节点里直接调大基础色来补偿曝光不足,这是很危险的做法。材质基础色代表物体的物理反照率,正常范围在0.02到0.8之间。一旦把基础色调到1以上,后续多盏灯叠加、环境光遮蔽、反射捕获都会失真。更好的做法是在场景级或摄像机级统一调整曝光,让所有材质遵循同一物理范围。
下面是一段典型的延迟渲染后处理代码,曝光系数和色调映射配合使用。
// 输入HDR线性颜色 vec3 hdrColor = texture(hdrBuffer, uv).rgb; // 曝光通常使用EV值换算,EV每增加1,亮度翻倍 float exposure = pow(2.0, ev); vec3 exposedColor = hdrColor * exposure; // 色调映射将HDR压缩到可显示范围 vec3 toneMapped = exposedColor / (exposedColor + vec3(1.0)); // 最后才做伽马编码 vec3 finalColor = linearToSrgb(toneMapped); fragColor = vec4(finalColor, 1.0);
自动曝光又是另一个话题。它通过读取画面平均亮度动态调整EV,使亮场景和暗场景都能保留细节。但自动曝光需要在不被色调映射污染的HDR缓冲上计算平均亮度,如果缓冲已经被压成LDR,自动曝光就会反复补偿,出现画面忽明忽暗。因此,曝光控制的位置必须固定在色调映射之前,并且所有材质输出保持线性HDR,才能得到稳定结果。
三、伽马校正不一致导致的过亮和过暗
伽马校正的核心思想是:渲染器在线性空间计算,显示器在sRGB空间显示,中间需要一次编码转换。但这条链路非常容易因为某一个环节重复转换或漏掉转换而出现亮度异常。常见的一种情况是:贴图已经被硬件解码为线性空间,但材质节点里又手动Decode,导致颜色被解码两次,暗部被过度提亮。另一种是:后期栈中的某个效果没有保持线性,提前做了伽马编码,后续又做了色调映射,结果高光过曝。
要避免重复伽马校正,需要明确每个缓冲区的颜色空间。例如前向渲染的默认帧缓冲通常是sRGB格式,硬件会在写入时自动把线性值编码为sRGB。如果你在着色器里手动做了linearToSrgb,又写入sRGB帧缓冲,就会导致双重编码,画面变暗。反之,如果使用线性格式的浮点缓冲,就需要自己完成伽马编码后再输出到屏幕。
可以使用一个简单的伪代码检查项:凡是待显示的LDR颜色,必须恰好经过一次伽马编码;凡是参与光照计算的颜色,必须恰好经过一次sRGB解码。中间任何HDR缓冲都保持线性。若你发现某张颜色贴图在场景中过亮,先检查它的导入设置是否被标记为sRGB;如果过暗,检查输出链路上是否有两个地方在做伽马校正。
四、从贴图到后期:材质亮度排查清单
实际项目中,材质过亮或过暗往往是多种因素叠加。建议从源头开始排查,而不是直接调材质参数。第一步检查贴图格式:颜色贴图、基础色贴图、自发光贴图应当使用sRGB编码,法线贴图、金属度贴图、粗糙度贴图、高度贴图应当使用线性编码。很多引擎在导入纹理时会自动设置,但批量导入或自定义导入器可能出错。
第二步检查材质节点中的颜色常量。颜色选择器里的数值通常是sRGB空间的显示值,但引擎在传给着色器时可能会自动转为线性。不同引擎行为不同,有的需要手动转换。遇到偏色或亮度异常,可以把材质颜色设为0.5灰,与贴图采样结果做对比,判断是常量转换问题还是贴图采样问题。
第三步检查摄像机和后期设置。曝光是否只应用在渲染完成之后?色调映射是否开启?默认的色调映射曲线会压缩高光,如果你在材质节点中手动加了高光补偿,画面就会过曝。关闭后期效果看原始线性输出,有助于分离问题来源。
下面给出一个排查表,可以按顺序检查。
| 检查项 | 正确设置 | 常见错误 |
|---|---|---|
| 颜色贴图导入 | 标记为sRGB | 当作线性数据导致偏暗 |
| 法线贴图导入 | 保持线性 | 被sRGB解码导致光影错乱 |
| 曝光节点位置 | 线性HDR阶段 | 在sRGB输出阶段乘曝光 |
| 伽马编码次数 | 只做一次 | 着色器手动编码后硬件又编码 |
理清这些设置后,大部分材质过亮或过暗的问题都能定位。曝光负责整体亮度,伽马校正负责显示编码,两者分别对应线性HDR和sRGB LDR,任何一方错位都会让材质表现失真。把管线统一到线性工作流,并保持每个缓冲区职责清晰,比不断调整材质基础色和灯光强度更可靠。