GameTech/URP 渲染实战

案例 11.4多光源与阴影:Forward 与 Forward+进阶

目标:在两种渲染路径下都正确遍历附加光,接收主光与附加光阴影。

Assets/Cookbook/Ch11/MyPBR_Lighting.hlsl(节选:evaluate_light)
// 一盏灯对出射辐射率的贡献:Lo = f(l,v) * E⊥ * NoL
// Unity 的光源约定:light.color 是“白色 Lambert 表面正对灯时看到的亮度”,
// 即 Lo = albedo/π * E⊥  =>  E⊥ = π * light.color。所以这里乘 π,与 BRDF 里的 1/π 互相抵消。
void MyAccumulateLight(MyMaterial m, float3 N, float3 V, Light light, inout MyLightingTerms t)
{
    float3 fd, fs; float NoL;
    MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL);
    float3 E = PI * light.color * (light.distanceAttenuation * light.shadowAttenuation) * NoL;
    t.directDiffuse  += fd * E;
    t.directSpecular += fs * E;
}
// 一盏灯对出射辐射率的贡献:Lo = f(l,v) * E⊥ * NoL
// Unity 的光源约定:light.color 是“白色 Lambert 表面正对灯时看到的亮度”,
// 即 Lo = albedo/π * E⊥  =>  E⊥ = π * light.color。所以这里乘 π,与 BRDF 里的 1/π 互相抵消。
void MyAccumulateLight(MyMaterial m, vec3 N, vec3 V, Light light, inout MyLightingTerms t)
{
    vec3 fd, fs; float NoL;
    MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL);
    vec3 E = PI * light.color * (light.distanceAttenuation * light.shadowAttenuation) * NoL;
    t.directDiffuse  += fd * E;
    t.directSpecular += fs * E;
}

每盏灯的贡献都走 MyAccumulateLight:先算 BRDF,再乘 E⊥=π·color、距离衰减、阴影衰减和 NoL。light.distanceAttenuation 对点光、聚光已经包含了 URP 的平滑距离衰减和聚光锥角衰减(c06 讲过公式),对方向光是 1。

Assets/Cookbook/Ch11/MyPBR_Lighting.hlsl(节选:compute_lighting)
MyLightingTerms MyComputeLighting(MySurface s, inout MyMaterial m, InputData inputData)
{
    MyLightingTerms t = (MyLightingTerms)0;
    float3 N = inputData.normalWS;
    float3 V = inputData.viewDirectionWS;
    float NoV = saturate(dot(N, V)) + 1e-5;

    // ---- 环境 BRDF 与能量补偿:只和 NoV、粗糙度有关,先算好给直接光也用 ----
    float2 dfg = MyEnvDFG(NoV, m.perceptualRoughness);
    m.energyCompensation = lerp(1.0, MyEnergyCompensation(dfg, m.f0), _EnergyCompensation);

    // ---- 遮蔽:材质 AO 与 SSAO 取最小值(URP 的约定)----
    AmbientOcclusionFactor ao = CreateAmbientOcclusionFactor(inputData.normalizedScreenSpaceUV, s.occlusion);
    half4 shadowMask = CalculateShadowMask(inputData);
    uint meshLayers = GetMeshRenderingLayer();

    // ---- 主光 ----
    Light mainLight = GetMainLight(inputData, shadowMask, ao);   // 已含阴影、Light Cookie、SSAO 直接光部分
    MixRealtimeAndBakedGI(mainLight, N, inputData.bakedGI);      // Subtractive 光照贴图模式下扣掉主光
#if defined(_LIGHT_LAYERS)
    if (IsMatchingLightLayer(mainLight.layerMask, meshLayers))
#endif
        MyAccumulateLight(m, N, V, mainLight, t);

    // ---- 附加光:Forward 逐物体列表 / Forward+ 分簇列表 ----
#if defined(_ADDITIONAL_LIGHTS)
    uint lightCount = GetAdditionalLightsCount();

    #if USE_FORWARD_PLUS
    // Forward+ 下,除主光外的方向光不进分簇,排在附加光数组最前面,要单独循环
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, ao);
        #if defined(_LIGHT_LAYERS)
        if (IsMatchingLightLayer(light.layerMask, meshLayers))
        #endif
            MyAccumulateLight(m, N, V, light, t);
    }
    #endif

    // LIGHT_LOOP_BEGIN 在 Forward 下展开成 for 循环,在 Forward+ 下展开成分簇迭代器,
    // 宏内部会用到名为 inputData 的变量(normalizedScreenSpaceUV、positionWS)
    LIGHT_LOOP_BEGIN(lightCount)
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, ao);
        #if defined(_LIGHT_LAYERS)
        if (IsMatchingLightLayer(light.layerMask, meshLayers))
        #endif
            MyAccumulateLight(m, N, V, light, t);
    LIGHT_LOOP_END
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光只有漫反射:顶点里累加的是 color * atten * NoL,等价于 E/π
    t.directDiffuse += inputData.vertexLighting * m.diffuseColor;
#endif

    // ---- 间接漫反射:SH(或光照贴图)----
    // Unity 的 SH / 光照贴图里存的是 E/π(“已经除过 π 的辐照度”),直接乘漫反射色即可
    t.indirectDiffuse = inputData.bakedGI * m.diffuseColor * ao.indirectAmbientOcclusion;

    // ---- 间接镜面:split-sum = 预滤波环境 × 环境 BRDF ----
    float3 R = reflect(-V, N);
    float3 prefiltered = MySamplePrefilteredEnv(R, m.perceptualRoughness, inputData.positionWS, inputData.normalizedScreenSpaceUV);
    float3 envBRDF = m.f0 * dfg.x + dfg.y;     // F90 = 1
    float specOcc = lerp(ao.indirectAmbientOcclusion,
                         MySpecularOcclusion(NoV, ao.indirectAmbientOcclusion, m.perceptualRoughness),
                         _SpecularOcclusion);
    t.indirectSpecular = prefiltered * envBRDF * m.energyCompensation * specOcc;

    t.emission = s.emission;
    return t;
}
MyLightingTerms MyComputeLighting(MySurface s, inout MyMaterial m, InputData inputData)
{
    MyLightingTerms t;
    t.directDiffuse = vec3(0.0); t.directSpecular = vec3(0.0);
    t.indirectDiffuse = vec3(0.0); t.indirectSpecular = vec3(0.0); t.emission = vec3(0.0);
    vec3 N = inputData.normalWS;
    vec3 V = inputData.viewDirectionWS;
    float NoV = saturate(dot(N, V)) + 1e-5;

    // ---- 环境 BRDF 与能量补偿:只和 NoV、粗糙度有关,先算好给直接光也用 ----
    vec2 dfg = MyEnvDFG(NoV, m.perceptualRoughness);
    m.energyCompensation = mix(vec3(1.0), MyEnergyCompensation(dfg, m.f0), _EnergyCompensation);

    // ---- 遮蔽:材质 AO 与 SSAO 取最小值(URP 的约定)----
    AmbientOcclusionFactor ao = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV);
    ao.indirectAmbientOcclusion = min(ao.indirectAmbientOcclusion, s.occlusion);
    vec4 shadowMask = inputData.shadowMask;

    // ---- 主光 ----
    Light mainLight = MyGetMainLight(inputData, shadowMask, ao);   // 已含阴影、SSAO 直接光部分
    // (HLSL 版这里还有 MixRealtimeAndBakedGI 与 Light Layers 判断;网页版没有光照贴图和灯光层)
    MyAccumulateLight(m, N, V, mainLight, t);

    // ---- 附加光:Forward 逐物体列表 / Forward+ 全部可见光 ----
#if defined(_ADDITIONAL_LIGHTS)
    // HLSL 版用 LIGHT_LOOP_BEGIN/END(Forward+ 下展开成分簇迭代器);
    // 网页版的 GetAdditionalLightsCount() 已经按渲染路径返回本像素要算的灯数,普通 for 循环即可
    int lightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
    {
        Light light = MyGetAdditionalLight(lightIndex, inputData, shadowMask, ao);
        MyAccumulateLight(m, N, V, light, t);
    }
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光只有漫反射:顶点里累加的是 color * atten * NoL,等价于 E/π
    t.directDiffuse += inputData.vertexLighting * m.diffuseColor;
#endif

    // ---- 间接漫反射:SH(或光照贴图)----
    // Unity 的 SH / 光照贴图里存的是 E/π(“已经除过 π 的辐照度”),直接乘漫反射色即可
    t.indirectDiffuse = inputData.bakedGI * m.diffuseColor * ao.indirectAmbientOcclusion;

    // ---- 间接镜面:split-sum = 预滤波环境 × 环境 BRDF ----
    vec3 R = reflect(-V, N);
    vec3 prefiltered = MySamplePrefilteredEnv(R, m.perceptualRoughness, inputData.positionWS, inputData.normalizedScreenSpaceUV);
    vec3 envBRDF = m.f0 * dfg.x + dfg.y;     // F90 = 1
    float specOcc = mix(ao.indirectAmbientOcclusion,
                        MySpecularOcclusion(NoV, ao.indirectAmbientOcclusion, m.perceptualRoughness),
                        _SpecularOcclusion);
    t.indirectSpecular = prefiltered * envBRDF * m.energyCompensation * specOcc;

    t.emission = s.emission;
    return t;
}

附加光部分是这一节的重点。LIGHT_LOOP_BEGIN 在两种路径下展开成完全不同的代码(摘自 URP 14 ShaderLibrary/RealtimeLights.hlsl):

#if USE_FORWARD_PLUS
    #define LIGHT_LOOP_BEGIN(lightCount) { \
    uint lightIndex; \
    ClusterIterator _urp_internal_clusterIterator = ClusterInit(inputData.normalizedScreenSpaceUV, inputData.positionWS, 0); \
    [loop] while (ClusterNext(_urp_internal_clusterIterator, lightIndex)) { \
        lightIndex += URP_FP_DIRECTIONAL_LIGHTS_COUNT; \
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
#elif !_USE_WEBGL1_LIGHTS
    #define LIGHT_LOOP_BEGIN(lightCount) \
    for (uint lightIndex = 0u; lightIndex < lightCount; ++lightIndex) {
Forward:每个物体一张灯光表 地板(一个物体) 最多 N 盏(默认 4) 灰色的灯超出每物体上限,被整片丢掉 Forward+:屏幕分块 × 深度分段 2 盏 每个像素只遍历自己所在格子里的灯,没有每物体上限
图 11-6两种路径的附加光组织方式。Forward 下灯光表按“物体”分配,Forward+ 按“屏幕格子 + 深度段(Z-Bin)”分配。

Forward 下,URP 在 CPU 端给每个物体挑出影响它的灯(按重要性排序,数量受 URP Asset 的 Lighting › Additional Lights › Per Object Limit 限制),GetAdditionalLightsCount() 返回这个数量,GetAdditionalLight(i, …) 内部再把循环序号 i 换成全局灯光索引。Forward+ 下,GetAdditionalLightsCount() 直接返回 0(宏忽略这个参数),循环靠分簇迭代器驱动;但除主光之外的方向光不参与分簇,它们排在附加光数组最前面,数量是 URP_FP_DIRECTIONAL_LIGHTS_COUNT,必须像代码里那样在 LIGHT_LOOP 之前单独循环一遍——漏掉这段,第二盏方向光在 Forward+ 下就不亮。这段写法与 URP 的 UniversalFragmentPBR 完全对应。

阴影方面,主光阴影来自 GetMainLight(inputData, shadowMask, ao),附加光阴影来自 GetAdditionalLight(i, inputData, shadowMask, ao),二者内部根据 _MAIN_LIGHT_SHADOWS*、_ADDITIONAL_LIGHT_SHADOWS、_SHADOWS_SOFT* 关键字选择采样方式;shadowMask 用于 Shadowmask 光照模式下烘焙阴影与实时阴影的混合。我们需要做的只是:声明好关键字、给出 ShadowCaster Pass(案例 11.9)、用 light.shadowAttenuation 乘到光照里。

图 11-7同一个 MyPBR 场景、8 盏彩色点光源。左:Forward,每物体上限 4(URP 默认值),整块地板只收到 4 盏灯;中:Forward,上限改为 8,灯光完整;右:Forward+,没有每物体上限,结果与中图一致。

这张对比图揭示了 Forward 路径一个常被忽视的问题:一个大物体(地板、地形、大墙面)上的灯数受“每物体上限”约束,超出的灯不会逐渐变暗,而是整盏消失,移动相机或物体时还可能跳变。Forward+ 没有这个限制,所以本书的配图默认使用 Forward+。如果项目必须用 Forward(例如要支持不支持 Forward+ 的老设备),就要把大物体切块,或者提高上限。