多光源与阴影
上一章的 Shader 只认识一盏方向光,而且光线可以穿过任何物体。真实的场景里有台灯、路灯、火把和手电筒,物体会互相遮挡。本章先让 Shader 在 Forward 和 Forward+ 两条路径下都能遍历所有光源,再把阴影映射从原理到源码彻底拆开,最后组装出一个完整的 Blinn-Phong + 多光源 + 阴影 Shader——ClassicLit,后续章节都会在它的基础上扩展。
6.1 URP 如何组织多个光源
URP 把场景里的实时光源分成两类:主光源(Main Light)只有一盏,必须是方向光,挑选规则在 5.2 节讲过;其余所有可见的方向光、点光和聚光统称附加光(Additional Lights)。主光源的数据放在 _MainLightPosition、_MainLightColor 等独立的全局变量里,并享有专门的级联阴影;附加光的数据则打包成数组:
float4 _AdditionalLightsPosition[MAX_VISIBLE_LIGHTS]; // In Forward+, .a stores whether the light is using subtractive mixed mode. half4 _AdditionalLightsColor[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsAttenuation[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsSpotDir[MAX_VISIBLE_LIGHTS]; half4 _AdditionalLightsOcclusionProbes[MAX_VISIBLE_LIGHTS]; float _AdditionalLightsLayerMasks[MAX_VISIBLE_LIGHTS]; // we want uint[] but Unity api does not support it.
MAX_VISIBLE_LIGHTS 是每个相机最多能看到的附加光数量:桌面平台 256,移动平台 32,OpenGL ES 3.0 级别的低端设备 16(定义在 URP 配置包的 ShaderConfig.cs.hlsl 里)。问题是:一个像素不可能真的把 256 盏灯全算一遍。怎样只算“照得到它的那几盏”,就是渲染路径要解决的问题。
Forward 路径以物体为单位剔除:渲染每个物体前,CPU 根据灯光的范围、强度与物体包围盒,为它挑出最重要的若干盏灯,下标写进 unity_LightIndices,数量写进 unity_LightData.y。数量受 URP Asset 中 限制,最大为 8。它的问题很明显:一个大物体(比如地面)身上可能落着十几盏灯,却最多只能算 8 盏,多出来的灯会突然“消失”;为了绕开限制,美术不得不把地面切成小块。
Forward+(URP 14 新增)以像素为单位剔除:把屏幕分成若干 tile,把视锥沿深度分成若干段(Z-Bin),在 GPU 上为每个 tile 和每个深度段各算一张“与哪些灯相交”的位掩码。着色时,像素根据自己的屏幕坐标和深度取出两张掩码求交,只遍历结果里的灯。它没有每物体上限,大地面上铺满灯也没问题;代价是需要额外的计算和缓冲。方向光照亮所有像素,没有必要放进分块结构,所以 Forward+ 把附加的方向光单独放在光源数组最前面,数量记在 URP_FP_DIRECTIONAL_LIGHTS_COUNT 里。
Deferred 路径把材质属性先写进 G-Buffer,再在屏幕空间逐光源累加,不在本章讨论范围(参见第 2 章)。需要注意的是:Deferred 下透明物体和“只能前向渲染”的材质仍然走前向光照,所以前向的多光源写法在任何路径下都用得上。
6.2 附加光循环:两条路径,一套写法
URP 用宏把两种剔除方式藏在同一套接口后面。核心是 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 #define LIGHT_LOOP_END } } #elif !_USE_WEBGL1_LIGHTS #define LIGHT_LOOP_BEGIN(lightCount) \ for (uint lightIndex = 0u; lightIndex < lightCount; ++lightIndex) { #define LIGHT_LOOP_END } #else ... #endif
读这段宏要注意四件事:
- USE_FORWARD_PLUS 从哪来。
Core.hlsl开头写着:如果定义了_FORWARD_PLUS,就定义USE_FORWARD_PLUS 1,同时强制定义_ADDITIONAL_LIGHTS并取消_ADDITIONAL_LIGHTS_VERTEX。而_FORWARD_PLUS是 URP 在 Forward+ 路径下打开的全局关键字。所以 Shader 必须声明#pragma multi_compile _ _FORWARD_PLUS,否则在 Forward+ 下拿到的是 Forward 版本的代码,而 Forward+ 根本不填每物体的光源列表——附加光会全部失效。 - Forward+ 版本偷偷引用了变量
inputData。宏展开后直接读inputData.normalizedScreenSpaceUV和inputData.positionWS,所以调用它的函数里必须有一个名叫inputData、类型为InputData的变量,并且这两个字段已经填好。名字写成别的会编译失败。 - Forward 版本的 lightCount 来自
GetAdditionalLightsCount()。在 Forward+ 下这个函数恒返回 0,宏也不使用它,循环靠分簇迭代器ClusterNext驱动。 - Forward+ 不包含附加方向光。迭代器得到的下标要加上
URP_FP_DIRECTIONAL_LIGHTS_COUNT,前面那几个方向光必须由我们自己再写一个 for 循环处理。URP 自己的UniversalFragmentPBR和UniversalFragmentBlinnPhong都是这么写的。
取到下标以后,用 GetAdditionalLight(lightIndex, positionWS) 得到一个 Light 结构体——和主光源完全一样的结构,于是“一盏灯怎么照亮表面”的代码可以原样复用。这个函数在 Forward 下会先通过 GetPerObjectLightIndex 把“本物体的第几盏灯”换算成全局下标,在 Forward+ 下则直接使用全局下标。它还有两个重载:多一个 shadowMask 参数的版本会顺带计算附加光阴影和光照 Cookie;再多一个 AmbientOcclusionFactor 的版本还会乘上 SSAO 的直接光遮蔽。
| 关键字 | 谁打开它 / 含义 |
|---|---|
_ADDITIONAL_LIGHTS | Forward 路径、Asset 中附加光设为 Per Pixel 时由 URP 打开:逐像素循环附加光 |
_ADDITIONAL_LIGHTS_VERTEX | 附加光设为 Per Vertex 时打开:在顶点里用 VertexLighting() 累加 Lambert |
_FORWARD_PLUS | Renderer 的 Rendering Path 为 Forward+ 时打开;Core.hlsl 据此定义 USE_FORWARD_PLUS 与 _ADDITIONAL_LIGHTS |
_ADDITIONAL_LIGHT_SHADOWS | Asset 开启附加光阴影且有灯投影时打开(见案例 6.7) |
_LIGHT_LAYERS | Asset 开启 Light Layers 时打开(见 6.4 节) |
_LIGHT_COOKIES | 场景中有灯使用 Cookie 贴图时打开 |
案例 6.1多光源 Lambert:同时支持 Forward 与 Forward+入门单独打开 ↗
目标:在第 5 章 Lambert 的基础上加入附加光循环,写出一个在 Forward(含逐顶点模式)和 Forward+ 下都能正确工作的最小多光源 Shader。
完整代码
// 案例 6.1 多光源 Lambert:主光 + 附加光循环,同时支持 Forward 与 Forward+ Shader "Cookbook/Ch06/MultiLightLambert" { Properties { [MainTexture] _BaseMap("Base Map", 2D) = "white" {} [MainColor] _BaseColor("Base Color", Color) = (0.8, 0.8, 0.8, 1) _AmbientStrength("Ambient Strength", Range(0, 1)) = 0.3 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half _AmbientStrength; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag // Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素) // 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS, // Core.hlsl 会据此自动定义 _ADDITIONAL_LIGHTS 和 USE_FORWARD_PLUS #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile _ _FORWARD_PLUS #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; #if defined(_ADDITIONAL_LIGHTS_VERTEX) half3 vertexLight : TEXCOORD3; #endif }; Varyings vert(Attributes input) { Varyings output; VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz); output.positionCS = pos.positionCS; output.positionWS = pos.positionWS; output.normalWS = TransformObjectToWorldNormal(input.normalOS); output.uv = TRANSFORM_TEX(input.uv, _BaseMap); #if defined(_ADDITIONAL_LIGHTS_VERTEX) // 逐顶点模式:URP 的 VertexLighting() 在顶点里把所有附加光的 Lambert 累加好 output.vertexLight = VertexLighting(pos.positionWS, output.normalWS); #endif return output; } half3 LambertLight(Light light, half3 normalWS) { half3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation); return radiance * saturate(dot(normalWS, light.direction)); } half4 frag(Varyings input) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; half3 normalWS = normalize(input.normalWS); // LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量 // (inputData.positionWS 与 inputData.normalizedScreenSpaceUV),所以名字不能改 InputData inputData = (InputData)0; inputData.positionWS = input.positionWS; inputData.normalWS = normalWS; inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); // 主光源 half3 lighting = LambertLight(GetMainLight(), normalWS); #if defined(_ADDITIONAL_LIGHTS) uint lightCount = GetAdditionalLightsCount(); // Forward+ 下恒为 0,循环靠 cluster 迭代器 #if USE_FORWARD_PLUS // Forward+:附加的“方向光”不进入分簇结构,排在光源数组最前面,要单独循环 for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++) { lighting += LambertLight(GetAdditionalLight(lightIndex, inputData.positionWS), normalWS); } #endif // Forward:for (lightIndex = 0; lightIndex < lightCount; ...) // Forward+:遍历当前像素所在 tile 与 z-bin 的光源位掩码 LIGHT_LOOP_BEGIN(lightCount) Light light = GetAdditionalLight(lightIndex, inputData.positionWS); lighting += LambertLight(light, normalWS); LIGHT_LOOP_END #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) lighting += input.vertexLight; #endif half3 color = albedo * lighting + albedo * SampleSH(normalWS) * _AmbientStrength; return half4(color, 1); } ENDHLSL } // ---- 投影与深度:见案例 6.4 与 6.8 ---- Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex ShadowCasterVertex #pragma fragment ShadowCasterFragment #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch06/ShadowCasterPass.hlsl" ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex DepthOnlyVertex #pragma fragment DepthOnlyFragment #include "Assets/Cookbook/Ch06/DepthOnlyPass.hlsl" ENDHLSL } } }
// 案例 6.1 多光源 Lambert:主光 + 附加光循环,同时支持 Forward 与 Forward+(GLSL 版,对应 Ch06/MultiLightLambert.shader) Shader "Cookbook/Ch06/MultiLightLambert" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform float _AmbientStrength; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM // Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素) // 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS。 // 网页版引擎始终定义 _ADDITIONAL_LIGHTS(逐像素),Forward / Forward+ 的区别由引擎在 // GetAdditionalLightsCount() / GetAdditionalLight() 内部处理(见下面的循环) #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS uniform sampler2D _BaseMap; VARYING vec2 uv; VARYING vec3 positionWS; VARYING vec3 normalWS; #if defined(_ADDITIONAL_LIGHTS_VERTEX) VARYING vec3 vertexLight; #endif #ifdef VERTEX void main() { VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz); gl_Position = pos.positionCS; positionWS = pos.positionWS; normalWS = TransformObjectToWorldNormal(normalOS); uv = TransformTex(texcoord0.xy, _BaseMap_ST); #if defined(_ADDITIONAL_LIGHTS_VERTEX) // 逐顶点模式:URP 的 VertexLighting() 在顶点里把所有附加光的 Lambert 累加好 vertexLight = vec3(0.0); for (int i = 0; i < GetAdditionalLightsCount(); i++) { Light l = GetAdditionalPerObjectLight(i, pos.positionWS); vertexLight += LightingLambert(l.color * l.distanceAttenuation, l.direction, normalWS); } #endif } #endif #ifdef FRAGMENT vec3 LambertLight(Light light, vec3 normalWS) { vec3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation); return radiance * saturate(dot(normalWS, light.direction)); } void main() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 N = normalize(normalWS); // HLSL 版里 LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量,所以要先填好它; // GLSL 版的循环是普通 for,这里保留同样的写法便于对照 InputData inputData = InitInputData(); inputData.positionWS = positionWS; inputData.normalWS = N; inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord); // 主光源 vec3 lighting = LambertLight(GetMainLight(), N); #if defined(_ADDITIONAL_LIGHTS) // Forward:lightCount = 本物体受影响的灯数(Per Object Limit 以内),循环变量是“本物体的第几盏灯”; // Forward+:HLSL 版先单独循环附加的方向光,再用 LIGHT_LOOP_BEGIN 遍历当前像素所在 tile / z-bin 的灯。 // 网页版没有分簇结构,Forward+ 下 GetAdditionalLightsCount() 直接返回全部可见附加光(方向光排在最前面), // 所以两种路径都写成同一个普通 for 循环 int lightCount = GetAdditionalLightsCount(); for (int lightIndex = 0; lightIndex < lightCount; lightIndex++) { Light light = GetAdditionalLight(lightIndex, inputData.positionWS); lighting += LambertLight(light, N); } #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) lighting += vertexLight; #endif vec3 color = albedo * lighting + albedo * SampleSH(N) * _AmbientStrength; fragColor = vec4(color, 1.0); } #endif ENDGLSL } // ---- 投影与深度:见案例 6.4 与 6.8 ---- Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #include "Ch06/ShadowCasterPass.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #include "Ch06/DepthOnlyPass.glsl" ENDGLSL } } }
关键代码讲解
// Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素) // 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS, // Core.hlsl 会据此自动定义 _ADDITIONAL_LIGHTS 和 USE_FORWARD_PLUS #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile _ _FORWARD_PLUS
// Forward 路径:URP 按 Asset 的“Additional Lights”设置打开 _ADDITIONAL_LIGHTS(逐像素) // 或 _ADDITIONAL_LIGHTS_VERTEX(逐顶点);Forward+ 路径打开 _FORWARD_PLUS。 // 网页版引擎始终定义 _ADDITIONAL_LIGHTS(逐像素),Forward / Forward+ 的区别由引擎在 // GetAdditionalLightsCount() / GetAdditionalLight() 内部处理(见下面的循环) #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
两行 multi_compile 组合出 3 × 2 = 6 个变体。注意 _ADDITIONAL_LIGHTS_VERTEX 与 _FORWARD_PLUS 同时开启的组合实际上不会出现(Core.hlsl 会在 Forward+ 下取消前者),URP 的构建期变体剥离也会删掉它们。
half4 frag(Varyings input) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; half3 normalWS = normalize(input.normalWS); // LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量 // (inputData.positionWS 与 inputData.normalizedScreenSpaceUV),所以名字不能改 InputData inputData = (InputData)0; inputData.positionWS = input.positionWS; inputData.normalWS = normalWS; inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); // 主光源 half3 lighting = LambertLight(GetMainLight(), normalWS); #if defined(_ADDITIONAL_LIGHTS) uint lightCount = GetAdditionalLightsCount(); // Forward+ 下恒为 0,循环靠 cluster 迭代器 #if USE_FORWARD_PLUS // Forward+:附加的“方向光”不进入分簇结构,排在光源数组最前面,要单独循环 for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++) { lighting += LambertLight(GetAdditionalLight(lightIndex, inputData.positionWS), normalWS); } #endif // Forward:for (lightIndex = 0; lightIndex < lightCount; ...) // Forward+:遍历当前像素所在 tile 与 z-bin 的光源位掩码 LIGHT_LOOP_BEGIN(lightCount) Light light = GetAdditionalLight(lightIndex, inputData.positionWS); lighting += LambertLight(light, normalWS); LIGHT_LOOP_END #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) lighting += input.vertexLight; #endif half3 color = albedo * lighting + albedo * SampleSH(normalWS) * _AmbientStrength; return half4(color, 1); }
void main() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 N = normalize(normalWS); // HLSL 版里 LIGHT_LOOP_BEGIN 在 Forward+ 下会直接读取名为 inputData 的变量,所以要先填好它; // GLSL 版的循环是普通 for,这里保留同样的写法便于对照 InputData inputData = InitInputData(); inputData.positionWS = positionWS; inputData.normalWS = N; inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord); // 主光源 vec3 lighting = LambertLight(GetMainLight(), N); #if defined(_ADDITIONAL_LIGHTS) // Forward:lightCount = 本物体受影响的灯数(Per Object Limit 以内),循环变量是“本物体的第几盏灯”; // Forward+:HLSL 版先单独循环附加的方向光,再用 LIGHT_LOOP_BEGIN 遍历当前像素所在 tile / z-bin 的灯。 // 网页版没有分簇结构,Forward+ 下 GetAdditionalLightsCount() 直接返回全部可见附加光(方向光排在最前面), // 所以两种路径都写成同一个普通 for 循环 int lightCount = GetAdditionalLightsCount(); for (int lightIndex = 0; lightIndex < lightCount; lightIndex++) { Light light = GetAdditionalLight(lightIndex, inputData.positionWS); lighting += LambertLight(light, N); } #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) lighting += vertexLight; #endif vec3 color = albedo * lighting + albedo * SampleSH(N) * _AmbientStrength; fragColor = vec4(color, 1.0); }
- 构造
inputData。只填循环需要的两个字段:世界坐标,以及用GetNormalizedScreenSpaceUV(input.positionCS)算出的屏幕 UV。片元着色器里SV_POSITION的 xy 是像素坐标,这个函数除以屏幕尺寸并处理了平台的 UV 翻转。 - 一盏灯的贡献写成函数
LambertLight(Light, N)。主光源、附加方向光、分簇里的点光/聚光都调用它。distanceAttenuation × shadowAttenuation一起乘进去,即使现在还没有阴影,将来换成带阴影的GetAdditionalLight重载也不用改这里。 - 两个循环。
#if USE_FORWARD_PLUS里的 for 循环只处理附加方向光;LIGHT_LOOP_BEGIN/END之间处理其余光源,宏内部提供了循环变量lightIndex。 - 逐顶点模式。Varyings 里多一个
vertexLight,在顶点着色器调用 URP 的VertexLighting()累加所有附加光的 Lambert,片元里加回去即可。

_FORWARD_PLUS;inputData.normalizedScreenSpaceUV 和 positionWS 是否赋值(漏赋值时编译能过,但所有像素都落在同一个 tile 上,光照会错乱成块状);是否漏掉了附加方向光的那个单独循环。6.3 点光与聚光的衰减
物理的衰减:平方反比
点光源向四面八方均匀发光,距离为 d 处的球面面积是 4πd²,同样的光通量摊在越来越大的球面上,辐照度就按 1/d² 下降。这是唯一“物理正确”的距离衰减。它有两个工程上的麻烦:一是 d → 0 时趋于无穷;二是它永远不会真正变成 0,而渲染器必须为每盏灯设一个有限的影响范围(Range),否则每盏灯都要影响所有像素,剔除就无从谈起。
URP 的做法:平方反比 × 平滑窗口
float DistanceAttenuation(float distanceSqr, half2 distanceAttenuation) { float lightAtten = rcp(distanceSqr); float2 distanceAttenuationFloat = float2(distanceAttenuation); // Use the smoothing factor also used in the Unity lightmapper. half factor = half(distanceSqr * distanceAttenuationFloat.x); half smoothFactor = saturate(half(1.0) - factor * factor); smoothFactor = smoothFactor * smoothFactor; return lightAtten * smoothFactor; }
其中 distanceAttenuation.x 由 C# 端设为 1/Range²(见 UniversalRenderPipelineCore.cs 的 GetPunctualLightDistanceAttenuation)。写成公式:
第一项是物理的平方反比;第二项是一个“窗口函数”:d 远小于 r 时接近 1,几乎不影响平方反比;d 接近 r 时平滑地降到 0,并且在 d = r 处函数值和导数都为 0,不会出现可见的边界。注释里说这个窗口与 Unity 光照烘焙器(Lightmapper)一致,这保证了同一盏灯的实时光照与烘焙光照在衰减上匹配。
聚光灯的角度衰减
聚光灯在距离衰减之外,还要乘一个角度衰减。设 S 为聚光灯照射方向的反方向(和 L 一样,从表面这一侧指回光源),θ 为 L 与 S 的夹角,外角 θo 和内角 θi 分别是 Spot Angle 和 Inner Spot Angle 的一半:
URP 在 C# 端预先算好 1/(cosθi − cosθo) 和 −cosθo/(cosθi − cosθo),存进 _AdditionalLightsAttenuation.zw,Shader 里只需要一次乘加:saturate(SdotL * z + w),再平方。点光源的 zw 被设成让结果恒为 1 的值,所以点光和聚光可以走同一段代码。
案例 6.2自己计算衰减:为什么一定要有平滑窗口进阶单独打开 ↗
目标:不调用 GetAdditionalLight(),直接读取 URP 上传的光源数组,自己计算距离和角度衰减;对比平方反比、线性和 URP 三种衰减,看清“窗口”在剔除中的作用。
// 不用 GetAdditionalLight(),直接读 URP 上传的光源数组,自己算衰减。 // URP 14 在所有平台都走常量数组(USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA 恒为 0) half3 PunctualLight(uint loopIndex, float3 positionWS, half3 normalWS) { #if USE_FORWARD_PLUS int i = int(loopIndex); // Forward+:循环变量就是全局光源下标 #else int i = GetPerObjectLightIndex(loopIndex); // Forward:先查“本物体的第几盏灯”对应的全局下标 #endif float4 lightPos = _AdditionalLightsPosition[i]; // w = 0 表示方向光 half3 color = _AdditionalLightsColor[i].rgb; half4 attenParam = _AdditionalLightsAttenuation[i]; // x = 1/range²,zw = 聚光角度参数 half4 spotDir = _AdditionalLightsSpotDir[i]; float3 lightVector = lightPos.xyz - positionWS * lightPos.w; float d2 = max(dot(lightVector, lightVector), HALF_MIN); half3 L = half3(lightVector * rsqrt(d2)); #if defined(_FALLOFF_INVERSESQUARE) float atten = rcp(d2); // 物理上的平方反比,没有窗口 #elif defined(_FALLOFF_LINEAR) float atten = saturate(1.0 - sqrt(d2 * attenParam.x)); // 1 - d/range #else float atten = DistanceAttenuation(d2, attenParam.xy); // URP:平方反比 × 平滑窗口 #endif atten *= AngleAttenuation(spotDir.xyz, L, attenParam.zw); // 聚光锥;点光的参数让它恒为 1 return color * atten * saturate(dot(normalWS, L)); }
// 不用 GetAdditionalLight(),直接读 URP 上传的光源数组,自己算衰减。 // (网页版的光源数组与 URP 14 同名:_AdditionalLightsPosition / Color / Attenuation / SpotDir) vec3 PunctualLight(int loopIndex, vec3 positionWS, vec3 normalWS) { // Forward+:循环变量就是全局光源下标;Forward:先查“本物体的第几盏灯”对应的全局下标。 // HLSL 版用 #if USE_FORWARD_PLUS 区分;网页版的 GetPerObjectLightIndex 在 Forward+ 下原样返回 int i = GetPerObjectLightIndex(loopIndex); vec4 lightPos = _AdditionalLightsPosition[i]; // w = 0 表示方向光 vec3 color = _AdditionalLightsColor[i].rgb; vec4 attenParam = _AdditionalLightsAttenuation[i]; // x = 1/range²,zw = 聚光角度参数 vec4 spotDir = _AdditionalLightsSpotDir[i]; vec3 lightVector = lightPos.xyz - positionWS * lightPos.w; float d2 = max(dot(lightVector, lightVector), HALF_MIN); vec3 L = lightVector * inversesqrt(d2); #if defined(_FALLOFF_INVERSESQUARE) float atten = 1.0 / d2; // 物理上的平方反比,没有窗口 #elif defined(_FALLOFF_LINEAR) float atten = saturate(1.0 - sqrt(d2 * attenParam.x)); // 1 - d/range #else float atten = DistanceAttenuation(d2, attenParam.xy); // URP:平方反比 × 平滑窗口 #endif atten *= AngleAttenuation(spotDir.xyz, L, attenParam.zw); // 聚光锥;点光的参数让它恒为 1 return color * atten * saturate(dot(normalWS, L)); }
- 下标换算。和
GetAdditionalLight内部一样:Forward 下循环变量是“本物体的第几盏灯”,要用GetPerObjectLightIndex查出全局下标;Forward+ 下循环变量本身就是全局下标。 - 方向光与点光统一处理。
_AdditionalLightsPosition的 w 分量对方向光为 0、对点光为 1,所以lightPos.xyz − positionWS × lightPos.w对方向光得到方向本身,对点光得到“从表面指向光源”的向量。这是 URP 的一个小技巧,省掉了分支。 - Range 不需要单独上传。
attenParam.x就是 1/Range²,线性衰减需要的 d/Range 用sqrt(d2 * attenParam.x)即可。

图 6-5 的前两格揭示了“窗口”的真正意义。剔除——无论是 Forward 的每物体列表,还是 Forward+ 的 tile/Z-Bin 掩码——都是保守的、粗粒度的:它只保证“Range 范围内的像素一定会算这盏灯”,却不保证“Range 范围外的像素一定不算”。如果衰减在 Range 处不为 0,那么剔除粒度就会直接暴露在画面上:Forward+ 下是 tile 形状的硬边,Forward 下则是物体一进一出范围时整块的亮度跳变。只有让衰减在 Range 处平滑归零,剔除才是“隐形”的。

6.4 Light Layers 简介
Culling Mask 用 GameObject 的 Layer 决定灯照哪些物体,但 Layer 同时还管着物理、相机可见性,很快就不够用。URP 14 提供了独立的渲染层(Rendering Layers):每个 Renderer 有一个 32 位的 renderingLayerMask,每盏灯在 Universal Additional Light Data 里也有一个 renderingLayers,两者按位与不为 0 时灯才照亮物体。这个检查发生在 Shader 里:
uint meshRenderingLayers = GetMeshRenderingLayer(); // 来自 unity_RenderingLayer ... #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers)) #endif color += ShadeOneLight(light, ...);
开启方法:URP Asset 的 ,URP 随即打开全局关键字 _LIGHT_LAYERS。自定义 Shader 需要声明 #pragma multi_compile _ _LIGHT_LAYERS 并在每盏灯的贡献外加上判断,案例 6.9 的 ClassicLit 已经包含了这段代码。

RenderingLayerUtils.ToValidRenderingLayers),新工程默认只有一个 Default 层,此时把灯设到其它层,它会变得什么都照不亮。6.5 阴影映射原理
光照模型回答的是“光到了这个点以后反射多少”,阴影回答的是更早的问题:光到底能不能到这个点。实时渲染几乎都用阴影映射(Shadow Mapping)来回答它,思路出奇地简单。
两趟渲染
- 阴影投射(Shadow Caster)阶段:把相机放到光源的位置,只渲染深度,得到一张阴影图(Shadow Map)。方向光用正交投影,聚光用透视投影,点光要朝 6 个方向各渲染一张。在 URP 里,这一步由
MainLightShadowCasterPass和AdditionalLightsShadowCasterPass完成,它们调用每个物体 Shader 里LightMode = ShadowCaster的 Pass。 - 阴影接收(Shadow Receiver)阶段:正常渲染时,把像素的世界坐标乘以“世界 → 阴影图”矩阵,得到它在阴影图上的 UV 和它在光源视角下的深度 z,再与阴影图中该 UV 处存储的深度比较:z 更远,说明光被别的东西挡住了。
URP 中,世界坐标到阴影图坐标的矩阵存放在 _MainLightWorldToShadow[](每个级联一个),由 TransformWorldToShadowCoord() 完成变换。这个矩阵已经包含了光源的 VP 矩阵、从 [−1,1] 到 [0,1] 的缩放偏移、平台的 Reversed-Z 处理,以及级联在阴影图集里的图块位置。比较操作则交给硬件:阴影图被声明为 TEXTURE2D_SHADOW,配合比较采样器 sampler_LinearClampCompare,SAMPLE_TEXTURE2D_SHADOW 直接返回比较结果(0 或 1),在线性过滤模式下硬件还会对相邻 2×2 个 texel 的比较结果做双线性插值。
走样与“阴影粉刺”
阴影图的分辨率有限,一个 texel 覆盖场景中一小块面积,只记录一个深度。当受光面相对光源倾斜时,同一个 texel 覆盖的表面上,各点的真实深度并不相同,而 texel 里只存了中心那一点的深度。于是表面上有一半的点“比记录的深度更远”,被判定为自己遮挡了自己,形成一条条明暗相间的条纹——这就是阴影粉刺(Shadow Acne),也叫自阴影走样。
深度偏移与 Peter-Panning
最直接的修正是给比较留一点余量:要么在接收端把 z 往光源方向拉近,要么在投射端把写入阴影图的深度推远。这就是深度偏移(Depth Bias)。它的副作用是:偏移量大于物体与地面接触处的深度差时,阴影会从物体脚下“脱开”,物体看起来像飘在空中——这被形象地称为 Peter-Panning(彼得·潘的影子会离开身体)。表面越倾斜,消除粉刺需要的偏移越大,所以单一的常数偏移很难两全。
法线偏移
另一种思路是把投射体沿法线向内收缩一点再写入阴影图,即法线偏移(Normal Bias)。它对倾斜表面特别有效:表面越是侧对光源,沿法线收缩在深度方向上产生的偏移越大,正好是粉刺最严重的地方。它的副作用是物体“变瘦”:阴影整体缩小,薄的物体(树叶、栏杆、薄板)的阴影可能出现漏洞甚至消失。
URP 两种偏移都在投射端的顶点着色器里完成,函数是 Shadows.hlsl 中的 ApplyShadowBias:
float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection) { float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS)); float scale = invNdotL * _ShadowBias.y; // normal bias is negative since we want to apply an inset normal offset positionWS = lightDirection * _ShadowBias.xxx + positionWS; positionWS = normalWS * scale.xxx + positionWS; return positionWS; }
_ShadowBias.x 和 .y 都是负数(C# 端取了负号),所以第一行把顶点沿“指向光源的方向”的反方向推远,第二行沿法线反方向向内收缩,收缩量再乘以 (1 − N·L)。这两个值的单位很讲究,C# 端的 ShadowUtils.GetShadowBias() 是这样算的:
// depth and normal bias scale is in shadowmap texel size in world space float texelSize = frustumSize / shadowResolution; float depthBias = -shadowData.bias[shadowLightIndex].x * texelSize; float normalBias = -shadowData.bias[shadowLightIndex].y * texelSize; ... if (shadowLight.lightType == LightType.Point) normalBias = 0.0f; ... // For now we scale it by the PCF kernel size of non-mobile platforms (5x5) depthBias *= kernelRadius; // Low 1.5 / Medium 2.5 / High 3.5(仅软阴影) normalBias *= kernelRadius;
也就是说:Asset 或灯光上填的 Depth Bias = 1,意思是“偏移 1 个阴影图 texel 在世界空间中的大小”。阴影图分辨率越低、阴影距离越远(texel 越大),实际偏移就越大,偏移会自动适应分辨率。开启软阴影时,偏移还要乘以滤波核的半径,因为 PCF 会采样到更远的 texel。点光源的法线偏移被强制设为 0(与 Built-in 管线的约定相同)。此外,URP 在渲染阴影图时还会通过 cmd.SetGlobalDepthBias(1.0f, 2.5f) 打开一个固定的硬件斜率偏移,所以即使把两个 Bias 都设为 0,粉刺也比教科书示意图里轻得多。
PCF:软化锯齿
阴影图的 texel 被放大到屏幕上时,阴影边缘会出现明显的锯齿。百分比渐近过滤(Percentage-Closer Filtering,PCF)的做法是:在阴影图上采样周围多个位置,分别做深度比较,再把 0/1 结果加权平均。注意顺序:是先比较、再平均,而不是先平均深度再比较——后者没有意义,因为深度的平均值不对应任何真实表面。
URP 14 把软阴影质量分为 Low、Medium、High 三档,对应 Shadows.hlsl 中的 SampleShadowmapFilteredLowQuality / MediumQuality / HighQuality:Low 是 4 次带硬件双线性比较的采样;Medium 和 High 使用 Core RP 的帐篷滤波(SampleShadow_ComputeSamples_Tent_5x5 / 7x7),利用每次硬件比较采样本身是 2×2 双线性的特点,用 9 次和 16 次采样分别覆盖 5×5 与 7×7 的区域。每盏灯可以单独选择质量(Light 组件上的 Soft Shadows Quality),默认使用 Asset 的设置。
级联阴影:近处清晰,远处够用
方向光的阴影图要覆盖从相机近处一直到阴影距离(Shadow Distance)的整个区域。如果只用一张图,近处一个 texel 可能对应几厘米甚至几十厘米,阴影糊成一片;而远处的物体在屏幕上很小,根本不需要那么高的精度。级联阴影(Cascaded Shadow Maps,CSM)把视锥沿深度切成几段,每段单独渲染一张阴影图:近处的段范围小、精度高,远处的段范围大、精度低,让阴影图的 texel 大小与屏幕像素大小大致匹配。
URP 的实现细节有几点值得知道:
- 级联数可选 1~4,分割比例由 Asset 中的 Cascade Split 决定(默认 4 级联为 6.7%、20%、46.7%)。所有级联共享一张阴影图集:2 个级联时图集高度减半、分成左右两块;3、4 个级联时分成 2×2 四块,每块边长是图集的一半。
- 每个级联用一个包围球而不是视锥切片来描述,球心和半径存在
_CascadeShadowSplitSpheres0~3与_CascadeShadowSplitSphereRadii里。用球的好处是:相机旋转时包围体不变,阴影图不会因为视锥朝向变化而“游动”闪烁。 - 着色时,
ComputeCascadeIndex()判断像素落在哪个球里,选第一个命中的级联;都没命中返回 4,对应一个“空”矩阵,结果视为受光。 - 阴影距离的最后一段(Asset 中的 Last Border,默认 20%)会用
GetMainLightShadowFade()让阴影平滑淡出,而不是在阴影距离处一刀切掉。
案例 6.3接收主光源阴影入门单独打开 ↗
目标:让 Lambert Shader 接收主光源的实时阴影,支持单张阴影图、级联、屏幕空间阴影和三档软阴影,并支持材质级的“不接收阴影”开关。
关键字
// 主光阴影:无 / 单张阴影图 / 级联 / 屏幕空间(由 URP Asset 与 Renderer 设置决定,运行时自动开关) #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN // 软阴影:_SHADOWS_SOFT 在运行时按每盏灯的质量分支;LOW/MEDIUM/HIGH 是给“必须静态分支”的平台准备的 #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH // 材质级开关:不接收阴影 #pragma shader_feature_local _RECEIVE_SHADOWS_OFF
这三行与 URP 14 Lit.shader 的写法一致:
_MAIN_LIGHT_SHADOWS:只有一个级联;_MAIN_LIGHT_SHADOWS_CASCADE:多级联;_MAIN_LIGHT_SHADOWS_SCREEN:Renderer 上加了 Screen Space Shadows 特性,阴影已经在一个屏幕空间 Pass 里算好,这里只需按屏幕 UV 采样(第 23 章)。三者都不开时,表示没有主光阴影。- 软阴影的四个关键字中,
_SHADOWS_SOFT在运行时读取每盏灯的质量设置做动态分支;_SOFT_LOW/MEDIUM/HIGH是给不擅长动态分支的平台准备的静态版本,URP 根据平台和设置自动选择。 _RECEIVE_SHADOWS_OFF是材质关键字:Shadows.hlsl开头检查它,定义了它就不会定义MAIN_LIGHT_CALCULATE_SHADOWS,阴影函数直接返回 1。
片元代码
half4 frag(Varyings input) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; half3 normalWS = normalize(input.normalWS); // 世界坐标 -> 阴影图坐标(xy = 阴影图 UV,z = 在光源空间里的深度)。 // 开了级联时,这里会先按距离选出级联号,再用对应的矩阵变换 float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS); // 采样阴影图并与 z 比较:1 = 受光,0 = 在阴影里(软阴影时是 0~1 的过渡) half shadow = MainLightRealtimeShadow(shadowCoord); // 超出“阴影距离”的地方,阴影要平滑淡出,而不是突然消失 shadow = lerp(shadow, 1.0, GetMainLightShadowFade(input.positionWS)); Light mainLight = GetMainLight(); half3 radiance = mainLight.color * mainLight.distanceAttenuation * shadow; half3 color = albedo * radiance * saturate(dot(normalWS, mainLight.direction)); color += albedo * SampleSH(normalWS) * _AmbientStrength; return half4(color, 1); }
void main() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 N = normalize(normalWS); // 世界坐标 -> 阴影图坐标(xy = 阴影图 UV,z = 在光源空间里的深度)。 // 开了级联时,这里会先按距离选出级联号,再用对应的矩阵变换 vec4 shadowCoord = TransformWorldToShadowCoord(positionWS); // 采样阴影图并与 z 比较:1 = 受光,0 = 在阴影里(软阴影时是 0~1 的过渡)。 // URP 的 MainLightRealtimeShadow 在 _RECEIVE_SHADOWS_OFF 时直接返回 1(宏 MAIN_LIGHT_CALCULATE_SHADOWS 未定义) #if defined(_RECEIVE_SHADOWS_OFF) float shadow = 1.0; #else float shadow = MainLightRealtimeShadow(shadowCoord); #endif // 超出“阴影距离”的地方,阴影要平滑淡出,而不是突然消失 shadow = mix(shadow, 1.0, GetMainLightShadowFade(positionWS)); Light mainLight = GetMainLight(); vec3 radiance = mainLight.color * mainLight.distanceAttenuation * shadow; vec3 color = albedo * radiance * saturate(dot(N, mainLight.direction)); color += albedo * SampleSH(N) * _AmbientStrength; fragColor = vec4(color, 1.0); }
- TransformWorldToShadowCoord:开了级联时先调用
ComputeCascadeIndex,再用对应的_MainLightWorldToShadow[i]变换;开了屏幕空间阴影时则返回屏幕坐标。 - MainLightRealtimeShadow:按关键字选择采样方式(屏幕空间纹理 / 硬阴影 / 某档 PCF),乘上灯光的 Shadow Strength,并对超出阴影图深度范围的坐标返回 1。
- GetMainLightShadowFade:返回 0~1 的淡出系数,用它把阴影插值回 1。URP 自己的
MainLightShadow()函数把实时阴影、烘焙阴影(Shadowmask)和淡出组合在一起,接入光照贴图时应该用它,这里为了看清每一步拆开来写。
Shadows.hlsl 在只有一个级联(或屏幕空间阴影)时会定义 REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR,提示你可以在顶点着色器里算好阴影坐标再插值——单个矩阵是线性变换,逐顶点算再插值与逐像素算结果相同,还省了一次矩阵乘法。多级联时必须逐像素计算,因为同一个三角形可能横跨两个级联。案例 6.9 的 ClassicLit 两种情况都处理了。

案例 6.4手写 ShadowCaster Pass进阶单独打开 ↗
目标:完整实现一个可复用的 ShadowCaster Pass,正确处理方向光与点光/聚光两种投射、深度与法线偏移、近平面压扁,以及 Alpha 裁剪材质的镂空阴影。
完整代码
// 案例 6.4 手写 ShadowCaster Pass:把物体画进光源的阴影图(只写深度)。 // 用法:在 Pass 里先声明好材质 CBUFFER(需要 Alpha 裁剪时要有 _BaseMap/_BaseColor/_Cutoff),再 include 本文件。 #ifndef COOKBOOK_CH06_SHADOW_CASTER_PASS_INCLUDED #define COOKBOOK_CH06_SHADOW_CASTER_PASS_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" // Shadows.hlsl 里的 SampleShadowmap 用到 LerpWhiteTo(),它在 CommonMaterial.hlsl 里,必须先 include #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" // 由 URP 的 MainLightShadowCasterPass / AdditionalLightsShadowCasterPass 在画每个阴影切片前设置 // (见 Runtime/ShadowUtils.cs 的 SetupShadowCasterConstantBuffer) float3 _LightDirection; // 方向光:指向光源的方向 float3 _LightPosition; // 点光 / 聚光:光源的世界坐标 struct ShadowCasterAttributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct ShadowCasterVaryings { #if defined(_ALPHATEST_ON) float2 uv : TEXCOORD0; #endif float4 positionCS : SV_POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID }; float4 GetShadowCasterPositionCS(float3 positionWS, float3 normalWS) { // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同 #if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW) float3 lightDirectionWS = normalize(_LightPosition - positionWS); #else float3 lightDirectionWS = _LightDirection; #endif // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间 // (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵) float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking), // 否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块 #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; } ShadowCasterVaryings ShadowCasterVertex(ShadowCasterAttributes input) { ShadowCasterVaryings output = (ShadowCasterVaryings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); float3 positionWS = TransformObjectToWorld(input.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(input.normalOS); output.positionCS = GetShadowCasterPositionCS(positionWS, normalWS); #if defined(_ALPHATEST_ON) output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); #endif return output; } half4 ShadowCasterFragment(ShadowCasterVaryings input) : SV_Target { UNITY_SETUP_INSTANCE_ID(input); #if defined(_ALPHATEST_ON) // 镂空材质的影子也要镂空:和颜色 Pass 用同样的 clip half alpha = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).a * _BaseColor.a; clip(alpha - _Cutoff); #endif return 0; } #endif
// 案例 6.4 手写 ShadowCaster Pass:把物体画进光源的阴影图(只写深度)。(GLSL 版,对应 Ch06/ShadowCasterPass.hlsl) // 用法:在 Pass 里先声明好材质 uniform(需要 Alpha 裁剪时要有 _BaseMap/_BaseMap_ST/_BaseColor/_Cutoff),再 include 本文件。 // ApplyShadowBias 在自动包含的 Lighting.glsl 里(对应 URP 的 Shadows.hlsl)。 // 由 URP 的 MainLightShadowCasterPass / AdditionalLightsShadowCasterPass 在画每个阴影切片前设置 // (见 Runtime/ShadowUtils.cs 的 SetupShadowCasterConstantBuffer)。 // GLSL 版里 _LightDirection / _LightPosition 已在 Lighting.glsl 里声明: // uniform vec3 _LightDirection; // 方向光:指向光源的方向 // uniform vec3 _LightPosition; // 点光 / 聚光:光源的世界坐标 #if defined(_ALPHATEST_ON) VARYING vec2 uv; #endif vec4 GetShadowCasterPositionCS(vec3 positionWS, vec3 normalWS) { // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同 // (HLSL 里的 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字,在 GLSL 版里是一个 uniform) vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间 // (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵) vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking), // 否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块 #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; } #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 normalWS = TransformObjectToWorldNormal(normalOS); gl_Position = GetShadowCasterPositionCS(positionWS, normalWS); #if defined(_ALPHATEST_ON) uv = TransformTex(texcoord0.xy, _BaseMap_ST); #endif } #endif #ifdef FRAGMENT void main() { #if defined(_ALPHATEST_ON) // 镂空材质的影子也要镂空:和颜色 Pass 用同样的 clip float alpha = texture(_BaseMap, uv).a * _BaseColor.a; if (alpha - _Cutoff < 0.0) discard; #endif fragColor = vec4(0.0); } #endif
关键代码讲解
float4 GetShadowCasterPositionCS(float3 positionWS, float3 normalWS) { // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同 #if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW) float3 lightDirectionWS = normalize(_LightPosition - positionWS); #else float3 lightDirectionWS = _LightDirection; #endif // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间 // (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵) float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking), // 否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块 #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; }
vec4 GetShadowCasterPositionCS(vec3 positionWS, vec3 normalWS) { // 1) 求“指向光源”的方向:方向光处处相同;点光/聚光每个顶点不同 // (HLSL 里的 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字,在 GLSL 版里是一个 uniform) vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; // 2) 在世界空间里做深度偏移 + 法线偏移,再变换到光源的裁剪空间 // (此刻 UNITY_MATRIX_VP 已被 URP 设成了“光源相机”的 VP 矩阵) vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS)); // 3) 把跑到近平面之外的顶点“压扁”到近平面上(Shadow Pancaking), // 否则光源近平面之前的投射体会被裁掉,阴影凭空缺一块 #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return positionCS; }
第一步,求光的方向。方向光的方向处处相同,URP 在绘制阴影前把它写进 _LightDirection;点光和聚光的方向随顶点而变,URP 写入的是光源位置 _LightPosition,要在顶点里自己求 normalize(_LightPosition − positionWS)。两种情况用 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字区分:URP 的 MainLightShadowCasterPass 在绘制前用 CoreUtils.SetKeyword 把它关掉,AdditionalLightsShadowCasterPass 把它打开。它只影响顶点着色器,所以用 multi_compile_vertex 声明,避免片元变体翻倍。
第二步,偏移并变换。ApplyShadowBias 已经在 6.5 节分析过。之后调用的 TransformWorldToHClip 和平时一样使用 UNITY_MATRIX_VP——在阴影 Pass 里,URP 已经把它设成了光源的视图投影矩阵,所以同一个函数自动就“从光源看过去”。
第三步,近平面压扁(Shadow Pancaking)。方向光的阴影相机的近平面被尽量贴近接收阴影的区域,以提高深度精度。可是投射阴影的物体可能在近平面之前(例如高处的树冠),如果被近平面裁掉,它的影子就凭空少一块。把 z 夹到近平面上,这些顶点就被“压扁”在近平面上,仍然写入了最近的深度、仍然能挡光。UNITY_NEAR_CLIP_VALUE 在 Reversed-Z 平台(D3D、Metal、Vulkan)上是 1,在 OpenGL 上是 −1,所以比较方向用 UNITY_REVERSED_Z 区分。
片元着色器只有在 _ALPHATEST_ON 时才做事:镂空材质(树叶、铁丝网)的影子也要镂空,必须用与颜色 Pass 相同的贴图、颜色和阈值做 clip。不需要裁剪时它什么也不输出,配合 Pass 上的 ColorMask 0,GPU 只写深度。
在 Shader 中使用它:
Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] HLSLPROGRAM #pragma target 2.0 #pragma vertex ShadowCasterVertex #pragma fragment ShadowCasterFragment #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma multi_compile_instancing // 区分方向光与点光/聚光:两者计算“指向光源方向”的方式不同 #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch06/ShadowCasterPass.hlsl" ENDHLSL }
Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] GLSLPROGRAM #pragma shader_feature_local_fragment _ALPHATEST_ON // 区分方向光与点光/聚光:两者计算“指向光源方向”的方式不同(GLSL 版里是 uniform _CastingPunctualLightShadow) #include "Ch06/ShadowCasterPass.glsl" ENDGLSL }
Pass 的渲染状态与 Lit 保持一致:ZWrite On、ZTest LEqual、ColorMask 0,Cull 跟随材质。材质 CBUFFER 写在 SubShader 的 HLSLINCLUDE 块里,被所有 Pass 共享——这是 SRP Batcher 的要求:同一个 Shader 的所有 Pass 必须使用布局完全相同的 UnityPerMaterial。

Material.SetShaderPassEnabled("ShadowCaster", false) 关闭了这个 Pass,物体不再出现在阴影图里,也就不投射阴影;右:恢复后正常投影。Renderer 上的 Cast Shadows 选项关掉时效果相同Shadows.hlsl 而没有 include CommonMaterial.hlsl,会报 undeclared identifier 'LerpWhiteTo'(第 5 章已提过)。② 顶点动画的 Shader(第 17 章)必须在 ShadowCaster 里做完全相同的顶点偏移,否则影子还停在原地。③ 用 UsePass "Universal Render Pipeline/Lit/ShadowCaster" 偷懒虽然能投影,但 Lit 的 Pass 使用 Lit 的 CBUFFER,与你的 Shader 不一致,整个 Shader 会失去 SRP Batcher 兼容性。案例 6.5阴影 Bias 调参对比进阶单独打开 ↗
目标:亲眼看到粉刺、Peter-Panning 和法线偏移过度三种问题,掌握 Bias 的调参方法。
测试场景:一个立方体放在地面上,一块悬空的薄板,一个球,主光源为高角度很低的侧光(20°),阴影图分辨率 512,软阴影 High——低分辨率和大滤波核都会放大偏移带来的问题。Depth Bias 与 Normal Bias 通过 URP Asset 修改(灯光上的 Bias 选项默认“Use settings from Render Pipeline Asset”):

调参的一般步骤:
- 先把两个 Bias 都设为 0,确认问题确实来自偏移不足(出现粉刺),而不是别的原因(比如法线反了、模型没有厚度)。
- 先加 Normal Bias,到粉刺基本消失为止。它对倾斜表面最有效,而且对接触处的影响比深度偏移小。
- 再用少量 Depth Bias 处理剩下的问题,同时观察物体与地面的接触处,一旦出现缝隙就往回调。
- 检查最薄的物体(树叶、栏杆、薄板)的影子,Normal Bias 过大时它们会先出问题。
- 修改阴影分辨率、阴影距离、级联数或软阴影质量后,重新检查——因为 Bias 的单位是“texel”,这些设置都会改变它的世界空间大小。
UniversalAdditionalLightData.usePipelineSettings = false),URP 就会改用 light.shadowBias 和 light.shadowNormalBias。为什么不在接收端做偏移?接收端偏移(比如根据屏幕空间导数估计表面斜率)可以更精确,HDRP 就有这样的选项;但它要在每个接收阴影的像素上额外计算,而投射端偏移只在阴影 Pass 的顶点上计算一次。URP 选择了更便宜的方案,ShadowUtils.cs 的注释里也坦率地说明了这是对透视阴影(聚光/点光)的“粗略近似”。
案例 6.6级联阴影可视化进阶单独打开 ↗
目标:写一个调试 Shader,按像素所在的级联染色,直观检查级联划分;再对比 1 个和 4 个级联的阴影精度。
half4 frag(Varyings input) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; half3 normalWS = normalize(input.normalWS); Light mainLight = GetMainLight(TransformWorldToShadowCoord(input.positionWS)); half3 color = albedo * mainLight.color * mainLight.shadowAttenuation * saturate(dot(normalWS, mainLight.direction)); color += albedo * SampleSH(normalWS) * 0.4; // ComputeCascadeIndex:依次判断像素是否落在 4 个“级联包围球”里,返回第一个命中的序号; // 4 表示超出了所有级联(阴影距离之外) #if defined(_MAIN_LIGHT_SHADOWS_CASCADE) half cascade = ComputeCascadeIndex(input.positionWS); #else half cascade = 0; // 只有一个级联时 URP 不会更新包围球数据,直接当作第 0 级 #endif const half3 kCascadeColors[5] = { half3(1.0, 0.25, 0.2), half3(0.25, 0.85, 0.3), half3(0.25, 0.45, 1.0), half3(1.0, 0.85, 0.2), half3(1, 1, 1) }; half3 tint = kCascadeColors[(uint)cascade]; color = lerp(color, color * tint * 1.6, _TintStrength); return half4(color, 1); }
void main() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 N = normalize(normalWS); Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS)); vec3 color = albedo * mainLight.color * mainLight.shadowAttenuation * saturate(dot(N, mainLight.direction)); color += albedo * SampleSH(N) * 0.4; // ComputeCascadeIndex:依次判断像素是否落在 4 个“级联包围球”里,返回第一个命中的序号; // 4 表示超出了所有级联(阴影距离之外)。 // HLSL 版用 _MAIN_LIGHT_SHADOWS_CASCADE 关键字区分;GLSL 版里它是 uniform:_MainLightShadowsCascade > 0.5 float cascade = 0.0; // 只有一个级联时 URP 不会更新包围球数据,直接当作第 0 级 if (_MainLightShadowsCascade > 0.5) cascade = ComputeCascadeIndex(positionWS); const vec3 kCascadeColors[5] = vec3[5]( vec3(1.0, 0.25, 0.2), vec3(0.25, 0.85, 0.3), vec3(0.25, 0.45, 1.0), vec3(1.0, 0.85, 0.2), vec3(1, 1, 1) ); vec3 tint = kCascadeColors[int(cascade)]; color = mix(color, color * tint * 1.6, _TintStrength); fragColor = vec4(color, 1.0); }
ComputeCascadeIndex 是 Shadows.hlsl 中的公开函数。只有一个级联时,URP 不会更新包围球数据,这时读出来的可能是残留值,所以要用 _MAIN_LIGHT_SHADOWS_CASCADE 关键字保护。返回值 4 表示超出了所有级联,用白色表示。


案例 6.7附加光阴影进阶单独打开 ↗
目标:理解点光、聚光阴影的存储方式,让多光源 Shader 正确接收它们。
附加光的阴影全部放在一张阴影图集 _AdditionalLightsShadowmapTexture 里:聚光灯占 1 个图块(透视投影),点光源占 6 个图块(立方体的 6 个面,每面 90° 多一点的透视投影,多出的角度用来给软阴影的滤波核留边)。每个图块的分辨率由灯光的 Shadow Resolution 档位(Low / Medium / High,像素数在 Asset 里设置)决定,图集放不下所有图块时 URP 会自动缩小它们(具体策略以实际版本为准)。每盏灯的阴影参数打包在 _AdditionalShadowParams[lightIndex] 里:x 是阴影强度,y 是软阴影质量,z 表示是否点光,w 是它的第一个图块的下标(−1 表示不投影)。
采样逻辑在 AdditionalLightRealtimeShadow 里:对点光源,先用 CubeMapFaceID(−lightDirection) 求出像素位于立方体的哪个面,把图块下标加上面号,再用对应的矩阵变换和采样。好消息是,这些都封装在 GetAdditionalLight(i, positionWS, shadowMask) 这个重载里——只要 Shader 声明了 _ADDITIONAL_LIGHT_SHADOWS,调用它返回的 light.shadowAttenuation 就已经是阴影结果。
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS LIGHT_LOOP_BEGIN(pixelLightCount) // 带 shadowMask 参数的重载:内部调用 AdditionalLightShadow(),还会乘上 Cookie Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask); color += Shade(light, ...); // Shade 里已经乘了 light.shadowAttenuation LIGHT_LOOP_END

_ADDITIONAL_LIGHT_SHADOWS。点光源要渲染 6 次阴影,开销是聚光灯的 6 倍,移动端要非常克制。案例 6.8DepthOnly 与 DepthNormals Pass入门单独打开 ↗
目标:弄清这两个 Pass 什么时候被调用、缺了会怎样,并写出可复用的实现。
除了颜色 Pass 和阴影 Pass,URP 还会在某些情况下单独渲染一遍场景的深度或深度+法线:
- DepthOnly:深度预渲染(Depth Prepass)。当某个功能需要相机深度纹理
_CameraDepthTexture、而又无法直接拷贝深度缓冲时(与平台、MSAA、渲染设置有关),URP 会先用所有物体的 DepthOnly Pass 画一遍深度。缺少这个 Pass 的物体不会出现在深度纹理里,软粒子、水的深浅、深度雾等效果会“看不见”它。 - DepthNormals:当有功能通过
ConfigureInput(ScriptableRenderPassInput.Normal)请求法线纹理_CameraNormalsTexture时触发,典型的就是 SSAO(Source 为 Depth Normals 时)、部分贴花模式和自定义的屏幕空间描边。这个 Pass 同时写深度和世界空间法线。
// DepthOnly Pass:只写深度。URP 做深度预渲染(Depth Prepass)、生成 _CameraDepthTexture 时会用到它。 #ifndef COOKBOOK_CH06_DEPTH_ONLY_PASS_INCLUDED #define COOKBOOK_CH06_DEPTH_ONLY_PASS_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct DepthOnlyAttributes { float4 positionOS : POSITION; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct DepthOnlyVaryings { #if defined(_ALPHATEST_ON) float2 uv : TEXCOORD0; #endif float4 positionCS : SV_POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; DepthOnlyVaryings DepthOnlyVertex(DepthOnlyAttributes input) { DepthOnlyVaryings output = (DepthOnlyVaryings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); output.positionCS = TransformObjectToHClip(input.positionOS.xyz); #if defined(_ALPHATEST_ON) output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); #endif return output; } half DepthOnlyFragment(DepthOnlyVaryings input) : SV_Target { UNITY_SETUP_INSTANCE_ID(input); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); #if defined(_ALPHATEST_ON) clip(SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).a * _BaseColor.a - _Cutoff); #endif // 深度由硬件写入;某些路径会把深度作为颜色写进 R 通道(Pass 里 ColorMask R),所以照 URP 的做法返回 z return input.positionCS.z; } #endif
// DepthOnly Pass:只写深度。URP 做深度预渲染(Depth Prepass)、生成 _CameraDepthTexture 时会用到它。(GLSL 版,对应 Ch06/DepthOnlyPass.hlsl) #if defined(_ALPHATEST_ON) VARYING vec2 uv; #endif #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); #if defined(_ALPHATEST_ON) uv = TransformTex(texcoord0.xy, _BaseMap_ST); #endif } #endif #ifdef FRAGMENT void main() { #if defined(_ALPHATEST_ON) if (texture(_BaseMap, uv).a * _BaseColor.a - _Cutoff < 0.0) discard; #endif // 深度由硬件写入;某些路径会把深度作为颜色写进 R 通道(Pass 里 ColorMask R),所以照 URP 的做法返回 z fragColor = vec4(gl_FragCoord.z); } #endif
// DepthNormals Pass:同时写深度和世界空间法线到 _CameraNormalsTexture。 // SSAO(Depth Normals 模式)、贴花、部分自定义屏幕空间效果都依赖它。 #ifndef COOKBOOK_CH06_DEPTH_NORMALS_PASS_INCLUDED #define COOKBOOK_CH06_DEPTH_NORMALS_PASS_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct DepthNormalsAttributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct DepthNormalsVaryings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO }; DepthNormalsVaryings DepthNormalsVertex(DepthNormalsAttributes input) { DepthNormalsVaryings output = (DepthNormalsVaryings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); output.positionCS = TransformObjectToHClip(input.positionOS.xyz); output.normalWS = TransformObjectToWorldNormal(input.normalOS); output.uv = input.texcoord; #if defined(_ALPHATEST_ON) output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); #endif return output; } void DepthNormalsFragment(DepthNormalsVaryings input , out half4 outNormalWS : SV_Target0 #ifdef _WRITE_RENDERING_LAYERS , out float4 outRenderingLayers : SV_Target1 #endif ) { UNITY_SETUP_INSTANCE_ID(input); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); #if defined(_ALPHATEST_ON) clip(SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).a * _BaseColor.a - _Cutoff); #endif // URP 14 的 _CameraNormalsTexture 直接存归一化的世界空间法线(不做 0.5 偏移) outNormalWS = half4(NormalizeNormalPerPixel(input.normalWS), 0.0); #ifdef _WRITE_RENDERING_LAYERS // 开启“渲染层”且有贴花等需求时,URP 会让这个 Pass 顺便写出网格的渲染层 outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0); #endif } #endif
// DepthNormals Pass:同时写深度和世界空间法线到 _CameraNormalsTexture。(GLSL 版,对应 Ch06/DepthNormalsPass.hlsl) // SSAO(Depth Normals 模式)、贴花、部分自定义屏幕空间效果都依赖它。 VARYING vec2 uv; VARYING vec3 normalWS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); normalWS = TransformObjectToWorldNormal(normalOS); uv = texcoord0.xy; #if defined(_ALPHATEST_ON) uv = TransformTex(texcoord0.xy, _BaseMap_ST); #endif } #endif #ifdef FRAGMENT void main() { #if defined(_ALPHATEST_ON) if (texture(_BaseMap, uv).a * _BaseColor.a - _Cutoff < 0.0) discard; #endif // URP 14 的 _CameraNormalsTexture 直接存归一化的世界空间法线(不做 0.5 偏移) // (网页版不支持“渲染层”贴图,_WRITE_RENDERING_LAYERS 那一路省略) fragColor = vec4(NormalizeNormalPerPixel(normalWS), 0.0); } #endif
两点说明:其一,DepthOnly 的片元返回 positionCS.z 并在 Pass 上声明 ColorMask R,与 URP 的 DepthOnlyPass.hlsl 一致——某些路径下深度会被写进一张颜色格式的纹理,这时 R 通道的值才是深度。其二,URP 14 的法线纹理直接存归一化的世界空间法线;只有 Deferred 开启 Accurate G-buffer normals 时才会用八面体编码(_GBUFFER_NORMALS_OCT),而 Lit 的 DepthNormals Pass 并不声明这个关键字。_WRITE_RENDERING_LAYERS 来自 RenderingLayers.hlsl(用 #include_with_pragmas 引入),开启渲染层并且贴花需要它时,这个 Pass 会顺便写出网格的渲染层。

UniversalForward 还是 UniversalForwardOnly?
URP 的 Deferred 路径会把有 UniversalGBuffer Pass 的材质写进 G-Buffer,再统一计算光照;没有 G-Buffer Pass 的材质,只有标记为 UniversalForwardOnly(或无标签的旧式 Pass)时,才会在 Deferred 的“Forward Only”通道里前向绘制。只标记 UniversalForward 又没有 G-Buffer Pass 的材质,在 Deferred 下根本不会被绘制。所以光照模型无法写进 G-Buffer 的自定义 Shader(Blinn-Phong、卡通、皮肤……)应该用 UniversalForwardOnly,它在 Forward / Forward+ 下与 UniversalForward 的行为完全相同。配套地,深度法线 Pass 要标 DepthNormalsOnly:Deferred 的深度法线预渲染只收集这个标签;Forward 下 DepthNormals 和 DepthNormalsOnly 都会被绘制,所以两者只能选一个。URP 自带的 Complex Lit 就是这样组织的。
案例 6.9ClassicLit:完整的 Blinn-Phong + 多光源 + 阴影 Shader高阶单独打开 ↗
目标:把本章所有内容组装成一个可以直接用于项目的 Shader:归一化 Blinn-Phong、球谐环境光、主光源级联软阴影、Forward / Forward+ 附加光循环、附加光阴影、逐顶点附加光、Light Layers、光照 Cookie、SSAO、雾效、Alpha 裁剪、GPU Instancing,以及完整的 ShadowCaster / DepthOnly / DepthNormals Pass,兼容 SRP Batcher,并能在 Forward、Forward+、Deferred 三种路径下工作。
文件组织仿照 URP 自己的 Lit:
| 文件 | 内容 |
|---|---|
ClassicLit.shader | 属性、渲染状态、各 Pass 的关键字声明 |
ClassicLitInput.hlsl | 材质 CBUFFER 与贴图声明,被所有 Pass 共享 |
ClassicLitForwardPass.hlsl | 前向光照的顶点/片元函数 |
ShadowCasterPass.hlsl 等 | 案例 6.4、6.8 写好的通用 Pass |
Shader 主文件
// 案例 6.9 ClassicLit:完整的 Blinn-Phong + 多光源 + 阴影 Shader(后续章节会以它为基础扩展) Shader "Cookbook/Ch06/ClassicLit" { Properties { [MainTexture] _BaseMap("Base Map", 2D) = "white" {} [MainColor] _BaseColor("Base Color", Color) = (1, 1, 1, 1) _SpecColor("Specular Color", Color) = (0.2, 0.2, 0.2, 1) _Smoothness("Smoothness", Range(0, 1)) = 0.5 [HDR] _EmissionColor("Emission", Color) = (0, 0, 0, 1) [Toggle(_ALPHATEST_ON)] _AlphaClip("Alpha Clip", Float) = 0 _Cutoff("Alpha Cutoff", Range(0, 1)) = 0.5 [Toggle(_RECEIVE_SHADOWS_OFF)] _ReceiveShadowsOff("Receive Shadows Off", Float) = 0 [Enum(UnityEngine.Rendering.CullMode)] _Cull("Cull", Float) = 2 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Assets/Cookbook/Ch06/ClassicLitInput.hlsl" ENDHLSL Pass { Name "ForwardLit" // 没有 GBuffer Pass 的自定义光照 Shader 要标成 UniversalForwardOnly: // Forward / Forward+ 下它和 UniversalForward 一样被绘制;Deferred 下它会进入“Forward Only”通道, // 而只标 UniversalForward 的物体在 Deferred 里会直接消失 Tags { "LightMode"="UniversalForwardOnly" } Cull [_Cull] HLSLPROGRAM #pragma target 2.0 #pragma vertex ClassicLitVertex #pragma fragment ClassicLitFragment // 材质关键字 #pragma shader_feature_local _RECEIVE_SHADOWS_OFF #pragma shader_feature_local_fragment _ALPHATEST_ON // URP 关键字(与 URP 14 Lit.shader 的 ForwardLit 保持一致的子集) #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION #pragma multi_compile_fragment _ _LIGHT_COOKIES #pragma multi_compile _ _LIGHT_LAYERS #pragma multi_compile _ _FORWARD_PLUS #pragma multi_compile_fog #pragma multi_compile_instancing #include "Assets/Cookbook/Ch06/ClassicLitForwardPass.hlsl" ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] HLSLPROGRAM #pragma target 2.0 #pragma vertex ShadowCasterVertex #pragma fragment ShadowCasterFragment #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma multi_compile_instancing // 区分方向光与点光/聚光:两者计算“指向光源方向”的方式不同 #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch06/ShadowCasterPass.hlsl" ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull [_Cull] HLSLPROGRAM #pragma target 2.0 #pragma vertex DepthOnlyVertex #pragma fragment DepthOnlyFragment #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma multi_compile_instancing #include "Assets/Cookbook/Ch06/DepthOnlyPass.hlsl" ENDHLSL } Pass { Name "DepthNormalsOnly" // 与 UniversalForwardOnly 配套:Deferred 的深度法线预渲染只收 DepthNormalsOnly; // Forward 下 DepthNormals 与 DepthNormalsOnly 都会被绘制,所以两者只能二选一 Tags { "LightMode"="DepthNormalsOnly" } ZWrite On Cull [_Cull] HLSLPROGRAM #pragma target 2.0 #pragma vertex DepthNormalsVertex #pragma fragment DepthNormalsFragment #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma multi_compile_instancing #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl" #include "Assets/Cookbook/Ch06/DepthNormalsPass.hlsl" ENDHLSL } } }
// 案例 6.9 ClassicLit:完整的 Blinn-Phong + 多光源 + 阴影 Shader(后续章节会以它为基础扩展)(GLSL 版,对应 Ch06/ClassicLit.shader) Shader "Cookbook/Ch06/ClassicLit" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE #include "Ch06/ClassicLitInput.glsl" ENDGLSL Pass { Name "ForwardLit" // 没有 GBuffer Pass 的自定义光照 Shader 要标成 UniversalForwardOnly: // Forward / Forward+ 下它和 UniversalForward 一样被绘制;Deferred 下它会进入“Forward Only”通道, // 而只标 UniversalForward 的物体在 Deferred 里会直接消失 Tags { "LightMode"="UniversalForwardOnly" } Cull [_Cull] GLSLPROGRAM // 材质关键字 #pragma shader_feature_local _RECEIVE_SHADOWS_OFF #pragma shader_feature_local_fragment _ALPHATEST_ON // URP 关键字:主光阴影、附加光(逐像素)、附加光阴影、软阴影由网页版引擎始终定义; // _FORWARD_PLUS、_LIGHT_LAYERS 在网页版里分别由引擎内部 / uniform _LightLayers 处理; // _SCREEN_SPACE_OCCLUSION 由 _AmbientOcclusionParam.x 判断;雾由 unity_FogMode 判断 #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #include "Ch06/ClassicLitForwardPass.glsl" ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] GLSLPROGRAM #pragma shader_feature_local_fragment _ALPHATEST_ON // 区分方向光与点光/聚光:两者计算“指向光源方向”的方式不同(GLSL 版里是 uniform _CastingPunctualLightShadow) #include "Ch06/ShadowCasterPass.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull [_Cull] GLSLPROGRAM #pragma shader_feature_local_fragment _ALPHATEST_ON #include "Ch06/DepthOnlyPass.glsl" ENDGLSL } Pass { Name "DepthNormalsOnly" // 与 UniversalForwardOnly 配套:Deferred 的深度法线预渲染只收 DepthNormalsOnly; // Forward 下 DepthNormals 与 DepthNormalsOnly 都会被绘制,所以两者只能二选一 Tags { "LightMode"="DepthNormalsOnly" } ZWrite On Cull [_Cull] GLSLPROGRAM #pragma shader_feature_local_fragment _ALPHATEST_ON #include "Ch06/DepthNormalsPass.glsl" ENDGLSL } } }
前向 Pass 的关键字是 URP 14 Lit.shader 中 ForwardLit 关键字的一个子集:去掉了光照贴图、反射探针混合、贴花缓冲、调试显示等第 8 章以后才涉及的部分。如果你的项目用到这些功能,回到 Lit.shader 按需补上。
材质输入
// 案例 6.9 ClassicLit 的材质输入:所有 Pass 共享同一个 UnityPerMaterial,保证 SRP Batcher 兼容 #ifndef COOKBOOK_CH06_CLASSIC_LIT_INPUT_INCLUDED #define COOKBOOK_CH06_CLASSIC_LIT_INPUT_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half4 _SpecColor; half _Smoothness; half4 _EmissionColor; half _Cutoff; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); #endif
// 案例 6.9 ClassicLit 的材质输入:所有 Pass 共享同一组材质参数(GLSL 版,对应 Ch06/ClassicLitInput.hlsl) // HLSL 版把它们放进 CBUFFER_START(UnityPerMaterial) 以兼容 SRP Batcher;GLSL 里就是普通 uniform。 uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform vec4 _SpecColor; uniform float _Smoothness; uniform vec4 _EmissionColor; uniform float _Cutoff; uniform sampler2D _BaseMap;
CBUFFER 被放在单独的文件里,通过 SubShader 级别的 HLSLINCLUDE 引入,四个 Pass 拿到的是同一份声明。_Cutoff 只有在开启 Alpha 裁剪时才被用到,但它必须始终存在于 CBUFFER 中:SRP Batcher 要求 CBUFFER 的布局不能随关键字变化。
顶点着色器
Varyings ClassicLitVertex(Attributes input) { Varyings output = (Varyings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz); half3 normalWS = TransformObjectToWorldNormal(input.normalOS); output.positionCS = vertexInput.positionCS; output.positionWS = vertexInput.positionWS; output.normalWS = normalWS; output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); half fogFactor = ComputeFogFactor(vertexInput.positionCS.z); half3 vertexLight = VertexLighting(vertexInput.positionWS, normalWS); // 只有 _ADDITIONAL_LIGHTS_VERTEX 时非零 output.fogFactorAndVertexLight = half4(fogFactor, vertexLight); #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) output.shadowCoord = GetShadowCoord(vertexInput); #endif return output; }
#ifdef VERTEX // URP 的 VertexLighting():只有 _ADDITIONAL_LIGHTS_VERTEX(逐顶点附加光)时才非零 vec3 VertexLighting(vec3 positionWS, vec3 normalWS) { vec3 vertexLightColor = vec3(0.0); #if defined(_ADDITIONAL_LIGHTS_VERTEX) for (int i = 0; i < GetAdditionalLightsCount(); i++) { Light light = GetAdditionalPerObjectLight(i, positionWS); vertexLightColor += LightingLambert(light.color * light.distanceAttenuation, light.direction, normalWS); } #endif return vertexLightColor; } void main() { VertexPositionInputs vertexInput = GetVertexPositionInputs(positionOS.xyz); vec3 nWS = TransformObjectToWorldNormal(normalOS); gl_Position = vertexInput.positionCS; positionWS = vertexInput.positionWS; normalWS = nWS; uv = TransformTex(texcoord0.xy, _BaseMap_ST); float fogFactor = ComputeFogFactor(vertexInput.positionCS.z); vec3 vertexLight = VertexLighting(vertexInput.positionWS, nWS); // 只有 _ADDITIONAL_LIGHTS_VERTEX 时非零 fogFactorAndVertexLight = vec4(fogFactor, vertexLight); } #endif
顶点阶段做三件“能提前算就提前算”的事:雾因子(URP 14 实际在片元里重新计算,见下)、逐顶点附加光 VertexLighting()(只有 _ADDITIONAL_LIGHTS_VERTEX 时才有内容,其余情况返回 0 并被编译器优化掉),以及在单级联时提前算好阴影坐标。
组装 InputData
InputData BuildInputData(Varyings input, half3 normalWS) { InputData inputData = (InputData)0; inputData.positionWS = input.positionWS; inputData.positionCS = input.positionCS; inputData.normalWS = normalWS; inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(input.positionWS); #if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR) inputData.shadowCoord = input.shadowCoord; #elif defined(MAIN_LIGHT_CALCULATE_SHADOWS) inputData.shadowCoord = TransformWorldToShadowCoord(input.positionWS); // 级联:逐像素选级联 #else inputData.shadowCoord = float4(0, 0, 0, 0); #endif // URP 14 强制逐像素算雾(ShaderVariablesFunctions.hlsl 里 #define _FOG_FRAGMENT 1),顶点雾因子只作后备 inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactorAndVertexLight.x); inputData.vertexLighting = input.fogFactorAndVertexLight.yzw; inputData.bakedGI = SampleSH(normalWS); // 本章只用球谐环境光,光照贴图见第 8 章 inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); inputData.shadowMask = half4(1, 1, 1, 1); // 没有烘焙阴影时为 1 return inputData; }
InputData BuildInputData(vec3 normalWS) { InputData inputData = InitInputData(); inputData.positionWS = positionWS; inputData.positionCS = gl_FragCoord; inputData.normalWS = normalWS; inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS); #if defined(_RECEIVE_SHADOWS_OFF) inputData.shadowCoord = vec4(0, 0, 0, 0); // 不接收阴影:URP 不定义 MAIN_LIGHT_CALCULATE_SHADOWS #else inputData.shadowCoord = TransformWorldToShadowCoord(positionWS); // 级联:逐像素选级联 #endif // URP 14 强制逐像素算雾(_FOG_FRAGMENT),这里按像素位置重新求雾因子,顶点雾因子只作后备 inputData.fogCoord = ComputeFogFactor(TransformWorldToHClip(positionWS).z); inputData.vertexLighting = fogFactorAndVertexLight.yzw; inputData.bakedGI = SampleSH(normalWS); // 本章只用球谐环境光,光照贴图见第 8 章 inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord); inputData.shadowMask = vec4(1, 1, 1, 1); // 没有烘焙阴影时为 1 return inputData; }
这个函数和 URP 的 InitializeInputData(LitForwardPass.hlsl)结构相同。之所以要把各种数据装进 InputData,除了 LIGHT_LOOP_BEGIN 的硬性要求,还因为 URP 的很多函数——GetMainLight(inputData, ...)、GetAdditionalLight(i, inputData, ...)、CalculateShadowMask——都以它为参数。注意雾:URP 14 在 ShaderVariablesFunctions.hlsl 里写死了 #define _FOG_FRAGMENT 1,雾一律按像素深度计算,所以要调用 InitializeInputDataFog 而不是直接使用顶点里的雾因子。
一盏灯的光照
// 一盏灯的贡献:归一化 Blinn-Phong(URP 约定,漫反射不除 π,高光系数为 (n+8)/8) half3 ClassicBlinnPhong(Light light, half3 normalWS, half3 viewDirWS, half3 albedo, half3 specColor, float shininess) { half NdotL = saturate(dot(normalWS, light.direction)); half3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation * NdotL); float3 H = SafeNormalize(float3(light.direction) + float3(viewDirWS)); float NdotH = saturate(dot(float3(normalWS), H)); float spec = (shininess + 8.0) * 0.125 * pow(NdotH, shininess); return (albedo + specColor * spec) * radiance; }
// 一盏灯的贡献:归一化 Blinn-Phong(URP 约定,漫反射不除 π,高光系数为 (n+8)/8) vec3 ClassicBlinnPhong(Light light, vec3 normalWS, vec3 viewDirWS, vec3 albedo, vec3 specColor, float shininess) { float NdotL = saturate(dot(normalWS, light.direction)); vec3 radiance = light.color * (light.distanceAttenuation * light.shadowAttenuation * NdotL); vec3 H = SafeNormalize(light.direction + viewDirWS); float NdotH = saturate(dot(normalWS, H)); float spec = (shininess + 8.0) * 0.125 * pow(NdotH, shininess); return (albedo + specColor * spec) * radiance; }
这就是第 5 章案例 5.6 的归一化 Blinn-Phong,只是把光的颜色、距离衰减、阴影衰减和 N·L 合成了一个 radiance。所有光源——主光源、附加方向光、点光、聚光——都调用它。
片元着色器
half4 ClassicLitFragment(Varyings input) : SV_Target { UNITY_SETUP_INSTANCE_ID(input); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); half4 baseSample = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; #if defined(_ALPHATEST_ON) clip(baseSample.a - _Cutoff); #endif half3 albedo = baseSample.rgb; half3 specColor = _SpecColor.rgb; float shininess = exp2(10.0 * _Smoothness + 1.0); // 与 URP SimpleLit 相同的映射 half3 normalWS = NormalizeNormalPerPixel(input.normalWS); InputData inputData = BuildInputData(input, normalWS); // 名字必须叫 inputData,LIGHT_LOOP_BEGIN 会用到 half4 shadowMask = CalculateShadowMask(inputData); AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData.normalizedScreenSpaceUV, 1.0); uint meshRenderingLayers = GetMeshRenderingLayer(); // ---- 环境光(SSAO 只压暗间接光,直接光按 Direct Lighting Strength 衰减)---- half3 color = inputData.bakedGI * albedo * aoFactor.indirectAmbientOcclusion; // ---- 主光源:带阴影、阴影淡出和 Cookie ---- Light mainLight = GetMainLight(inputData, shadowMask, aoFactor); #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(mainLight.layerMask, meshRenderingLayers)) #endif color += ClassicBlinnPhong(mainLight, normalWS, inputData.viewDirectionWS, albedo, specColor, shininess); // ---- 附加光源 ---- #if defined(_ADDITIONAL_LIGHTS) uint pixelLightCount = GetAdditionalLightsCount(); #if USE_FORWARD_PLUS 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, aoFactor); #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers)) #endif color += ClassicBlinnPhong(light, normalWS, inputData.viewDirectionWS, albedo, specColor, shininess); } #endif LIGHT_LOOP_BEGIN(pixelLightCount) // 这个重载会顺带算出附加光阴影(_ADDITIONAL_LIGHT_SHADOWS)与 Cookie Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor); #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers)) #endif color += ClassicBlinnPhong(light, normalWS, inputData.viewDirectionWS, albedo, specColor, shininess); LIGHT_LOOP_END #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) color += inputData.vertexLighting * albedo; // 逐顶点附加光:只有漫反射 #endif color += _EmissionColor.rgb; color = MixFog(color, inputData.fogCoord); return half4(color, baseSample.a); }
void main() { vec4 baseSample = texture(_BaseMap, uv) * _BaseColor; #if defined(_ALPHATEST_ON) if (baseSample.a - _Cutoff < 0.0) discard; #endif vec3 albedo = baseSample.rgb; vec3 specColor = _SpecColor.rgb; float shininess = exp2(10.0 * _Smoothness + 1.0); // 与 URP SimpleLit 相同的映射 vec3 N = NormalizeNormalPerPixel(normalWS); InputData inputData = BuildInputData(N); vec4 shadowMask = inputData.shadowMask; // URP:CalculateShadowMask(inputData) // URP:CreateAmbientOcclusionFactor(uv, 1.0)。没开 SSAO 时两项都是 1 AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV); uint meshRenderingLayers = GetMeshRenderingLayer(); // ---- 环境光(SSAO 只压暗间接光,直接光按 Direct Lighting Strength 衰减)---- vec3 color = inputData.bakedGI * albedo * aoFactor.indirectAmbientOcclusion; // ---- 主光源:带阴影、阴影淡出(URP 的 GetMainLight(inputData, shadowMask, aoFactor))---- Light mainLight = GetMainLight(inputData.shadowCoord, inputData.positionWS, shadowMask); mainLight.color *= aoFactor.directAmbientOcclusion; // HLSL 版的 #if defined(_LIGHT_LAYERS):GLSL 版里是 uniform _LightLayers(URP Asset 打开 Light Layers 时为 1) if (_LightLayers < 0.5 || IsMatchingLightLayer(GetMainLightLayerMask(), meshRenderingLayers)) color += ClassicBlinnPhong(mainLight, N, inputData.viewDirectionWS, albedo, specColor, shininess); // ---- 附加光源 ---- #if defined(_ADDITIONAL_LIGHTS) // HLSL 版:Forward+ 先单独循环附加方向光,再用 LIGHT_LOOP_BEGIN 遍历分簇结构; // 网页版统一成普通 for 循环(Forward 下是本物体的灯,Forward+ 下是全部可见附加光,方向光在最前面) int pixelLightCount = GetAdditionalLightsCount(); for (int lightIndex = 0; lightIndex < pixelLightCount; lightIndex++) { // 这个重载会顺带算出附加光阴影(_ADDITIONAL_LIGHT_SHADOWS) Light light = GetAdditionalLight(lightIndex, inputData.positionWS, shadowMask); #if defined(_RECEIVE_SHADOWS_OFF) light.shadowAttenuation = 1.0; #endif light.color *= aoFactor.directAmbientOcclusion; if (_LightLayers < 0.5 || IsMatchingLightLayer(GetAdditionalLightLayerMask(lightIndex), meshRenderingLayers)) color += ClassicBlinnPhong(light, N, inputData.viewDirectionWS, albedo, specColor, shininess); } #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) color += inputData.vertexLighting * albedo; // 逐顶点附加光:只有漫反射 #endif color += _EmissionColor.rgb; color = MixFog(color, inputData.fogCoord); fragColor = vec4(color, baseSample.a); }
- 阴影遮罩与 AO。
CalculateShadowMask在没有光照贴图时返回unity_ProbesOcclusion;CreateAmbientOcclusionFactor在开启 SSAO 时采样_ScreenSpaceOcclusionTexture,得到间接光遮蔽和直接光遮蔽两个值,没开启时都是 1。 - 主光源。
GetMainLight(inputData, shadowMask, aoFactor)一次完成:阴影采样、阴影淡出、烘焙阴影混合、Cookie、SSAO 对直接光的衰减。 - 附加光。结构与案例 6.1 相同,只是换成了带阴影和 AO 的
GetAdditionalLight重载,并加上 Light Layers 判断。宏FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK只在“Forward+ + 光照贴图 + Subtractive 模式”时展开为一个continue,用来跳过已经烘焙进光照贴图的灯,保留它是为了和 URP 的行为一致。 - 自发光与雾。自发光在雾之前加入,所以远处的灯泡会被雾吞没;如果希望自发光穿透雾,可以把它移到
MixFog之后。
效果



与 Simple Lit 对比,除了归一化,还有两处差异值得注意:Simple Lit 的高光项没有乘 N·L(见 CalculateBlinnPhong 与 LightingSpecular),光从侧后方掠射时,高光可能越过交界线出现在背光面上;Simple Lit 的光滑度在 _SPECULAR_COLOR 模式下实际取自 _SpecColor.a(材质面板会把 Smoothness 滑块写进这里),用脚本修改 _Smoothness 是无效的。
如何扩展
ClassicLit 是后续章节的“底座”:第 7 章会给它加上法线贴图(在 Varyings 里加切线、构建 TBN);第 8 章会接入光照贴图、反射探针和 Meta Pass;第 14 章的卡通渲染会把 ClassicBlinnPhong 换成色阶化的光照函数;第 17 章的顶点动画需要在 Forward、ShadowCaster、DepthOnly、DepthNormals 四个 Pass 里做相同的顶点偏移。由于光照函数、InputData 构建和各个 Pass 已经拆开,这些扩展都只需要改动很小的范围。
6.6 性能与移动端
多光源与阴影是渲染中最容易失控的开销,这里把本章涉及的性能要点集中列一下:
- 附加光的开销与“受影响的像素数 × 灯数”成正比。Range 越大,照到的像素越多;Forward+ 去掉了每物体上限,也就去掉了一道“天然的保险”,大量大范围的灯会让每个像素都循环很多次。
- 阴影的开销主要在投射端:每个级联、每盏投影的聚光、每个点光的 6 个面,都要把范围内的投射物体重新画一遍(只写深度,但 Draw Call 和顶点处理一样多)。关掉小物体的 Cast Shadows、减少级联、缩短阴影距离,通常比降低分辨率更有效。
- 接收端的开销取决于软阴影质量:High 每个像素 16 次比较采样,Low 只有 4 次。移动端建议 Low 或硬阴影。
- 移动端的附加光:低端设备可以把附加光设为 Per Vertex(Shader 要支持
_ADDITIONAL_LIGHTS_VERTEX),并且不开附加光阴影。 - 精度:衰减计算中的距离平方必须用 float,
Light.distanceAttenuation也声明为 float,half 在光源附近会溢出、在远处会损失精度。
本章小结
- URP 的光源分为主光源和附加光;Forward 按物体剔除(Per Object Limit ≤ 8),Forward+ 按屏幕 tile 和深度分段剔除,没有每物体上限。
- 附加光循环写法:声明
_ADDITIONAL_LIGHTS(_VERTEX)与_FORWARD_PLUS;准备名为inputData的变量;Forward+ 下单独循环附加方向光;其余用LIGHT_LOOP_BEGIN/END。 - URP 的距离衰减 = 平方反比 × (1 − (d²/r²)²)²,窗口让衰减在 Range 处平滑归零,从而隐藏剔除的粒度;聚光角度衰减是 saturate 后的平方。
- 阴影映射:先从光源渲染深度,再把像素变换到光源空间比较深度;粉刺用深度偏移和法线偏移消除,过度则产生 Peter-Panning 和“变瘦”;PCF 先比较后平均;级联让近处的阴影更清晰。
- URP 的 Bias 以阴影图 texel 为单位,在投射端由
ApplyShadowBias施加,软阴影时还会乘滤波核半径;渲染阴影图时另有固定的硬件斜率偏移。 - ShadowCaster 需要处理
_CASTING_PUNCTUAL_LIGHT_SHADOW、近平面压扁和 Alpha 裁剪,并与颜色 Pass 共享 CBUFFER。 - 没有 G-Buffer Pass 的自定义光照 Shader 应使用
UniversalForwardOnly与DepthNormalsOnly标签,才能在 Deferred 下正常显示。
练习
- 在案例 6.1 中,把附加方向光的单独循环删掉,然后在 Forward+ 下给场景加一盏非主光源的方向光,观察并解释现象。
- 修改 FalloffCompare,实现一个“距离衰减可调”的版本:在平方反比和 URP 衰减之间用一个参数插值,并思考它在什么样的风格化项目里有用。
- 给 ClassicLit 增加一个“阴影颜色”属性:让阴影区域不是变黑,而是向某个颜色过渡(提示:只修改主光源的 radiance 计算)。
- 用 CascadeDebug 观察:把相机快速旋转时,级联边界是否移动?把 Asset 中的 Shadow Distance 从 60 改为 30,各级联的覆盖范围怎样变化?
- 给 ClassicLit 的 ShadowCaster 加入顶点沿法线外扩的参数,模拟“更粗”的投影体,观察它对 Peter-Panning 的影响,并与 Normal Bias 的方向做对比。