案例 11.4多光源与阴影:Forward 与 Forward+进阶
目标:在两种渲染路径下都正确遍历附加光,接收主光与附加光阴影。
// 一盏灯对出射辐射率的贡献: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,再乘 、距离衰减、阴影衰减和 NoL。light.distanceAttenuation 对点光、聚光已经包含了 URP 的平滑距离衰减和聚光锥角衰减(c06 讲过公式),对方向光是 1。
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 下,URP 在 CPU 端给每个物体挑出影响它的灯(按重要性排序,数量受 URP Asset 的 限制),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 乘到光照里。

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