GameTech/URP 渲染实战

案例 9.9在 Gamma 空间算光照会怎样入门

目标:两盏彩色方向光照一个白球,对比线性空间与 Gamma 空间的光照结果。

做法:管线是线性的,我们“模拟”老式 Gamma 工作流:先把材质颜色和灯光颜色编码回 sRGB 数值,在编码后的数值上做 Lambert 乘加,最后再解码一次,以抵消管线最终写入 sRGB 目标时的编码,让屏幕显示的正是“在 Gamma 数值上算出来的结果”。

Assets/Cookbook/Ch09/GammaVsLinear.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float3 N = normalize(i.normalWS);
    Light l1 = GetMainLight();
    float3 L2 = normalize(_Light2Dir.xyz);

    // 材质颜色、灯光颜色在 Shader 里拿到的都已经是线性值
    float3 albedo = _BaseColor.rgb;
    float3 c1 = l1.color, c2 = _Light2Color.rgb;

    if (_GammaSpace > 0.5)
    {
        // 错误示范:把它们变回 sRGB 编码值再去做乘法和加法
        albedo = LinearToSRGB(albedo);
        c1 = LinearToSRGB(c1);
        c2 = LinearToSRGB(c2);
    }

    float3 lit = albedo * (c1 * saturate(dot(N, l1.direction)) + c2 * saturate(dot(N, L2)));

    if (_GammaSpace > 0.5)
        lit = SRGBToLinear(saturate(lit));  // 抵消管线最后的 sRGB 编码

    return half4(lit, 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    Light l1 = GetMainLight();
    vec3 L2 = normalize(_Light2Dir.xyz);

    // 材质颜色、灯光颜色在 Shader 里拿到的都已经是线性值
    vec3 albedo = _BaseColor.rgb;
    vec3 c1 = l1.color, c2 = _Light2Color.rgb;

    if (_GammaSpace > 0.5)
    {
        // 错误示范:把它们变回 sRGB 编码值再去做乘法和加法
        albedo = LinearToSRGB(albedo);
        c1 = LinearToSRGB(c1);
        c2 = LinearToSRGB(c2);
    }

    vec3 lit = albedo * (c1 * saturate(dot(N, l1.direction)) + c2 * saturate(dot(N, L2)));

    if (_GammaSpace > 0.5)
        lit = SRGBToLinear(saturate(lit));  // 抵消管线最后的 sRGB 编码

    fragColor = vec4(lit, 1.0);
}
图 9-25左:线性空间光照;右:Gamma 空间光照。两盏光(左橙右蓝)重叠的中间区域,线性空间里是自然的亮紫灰色;Gamma 空间里偏暗发脏,明暗交界线也更硬、更黑。

线性空间的结果之所以“对”,是因为光的叠加在物理上就是能量相加:50% 的橙光加 50% 的蓝光,总能量就是两者之和。Gamma 空间的数值不与能量成正比,加法失去了意义。这也是为什么“同一套美术资源,在 Gamma 项目里看起来对,搬到线性项目就偏亮偏灰”——当初在 Gamma 里调出来的参数本身就补偿了错误。新项目请一律使用线性空间。

移动端注意部分很老的 OpenGL ES 2.0 设备不支持 sRGB 读写,只能用 Gamma 空间。URP 14 仍保留了 Gamma 空间的支持(源码里可以看到 UNITY_COLORSPACE_GAMMA 分支),但 PBR 的结果在 Gamma 空间下只能算“近似可用”。今天的目标设备基本都支持线性空间。

HDR 与色调映射

辐射率没有上限。正午太阳下白墙的亮度,可能是室内暗角的数千倍;9.6 节算过,光滑表面的 GGX 峰值可以上千。如果帧缓冲只能存 0~1,这些信息在光照计算后就被截断了:高光变成一片纯白,Bloom 也无从提取“到底有多亮”。

所以 PBR 管线需要 HDR 渲染目标(URP 14 默认使用 32 位的 B10G11R11 浮点格式,可在 URP Asset 的 HDR Precision 里改为 64 位的 R16G16B16A16),让光照、半透明混合、后处理都在高动态范围下进行,最后由色调映射(Tonemapping)把它压缩到显示器能显示的 0~1,再做 sRGB 编码:

光照计算线性 HDR HDR 后处理Bloom、景深… 色调映射[0, ∞) → [0, 1] sRGB 编码线性 → 感知均匀 显示器 PBR 只负责第一步算对;后面三步决定“算对的数”怎么被看见
图 9-26从 HDR 光照到屏幕像素的流程。URP 中,色调映射由 Volume 里的 Tonemapping 组件控制,sRGB 编码由最终写入的目标格式完成。