案例 23.2平面投影阴影 + 模板防重叠进阶
目标:给角色 Shader 增加一个 Pass,把网格投影到 _PlaneHeight 高度的地面上,画成半透明阴影色;用模板测试保证同一像素只被阴影覆盖一次。
// 案例 23.3:平面投影阴影(Planar Shadow)。 // 第一个 Pass 正常着色;第二个 Pass(SRPDefaultUnlit)把同一个网格沿主光方向“拍扁”到地面上, // 输出半透明的阴影色,并用模板测试保证同一个像素只被阴影覆盖一次,避免重叠部分变黑。 Shader "Cookbook/Ch23/PlanarShadow" { Properties { [MainColor] _BaseColor ("Base Color", Color) = (0.9, 0.6, 0.3, 1) _ShadowColor ("Shadow Color", Color) = (0.05, 0.06, 0.1, 0.65) _PlaneHeight ("Plane Height (世界空间 y)", Float) = 0 _ShadowFalloff ("Height Falloff", Range(0, 2)) = 0.35 [Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp ("Stencil Comp", Float) = 6 // 6 = NotEqual } SubShader { // 排在普通不透明物体之后,保证地面已经画好,阴影才能混合到地面颜色上 Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry+10" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; half4 _ShadowColor; float _PlaneHeight; float _ShadowFalloff; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; half3 normalWS : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.normalWS = TransformObjectToWorldNormal(v.normalOS); return o; } half4 frag(Varyings i) : SV_Target { half3 n = normalize(i.normalWS); Light l = GetMainLight(); half3 c = _BaseColor.rgb * (l.color * saturate(dot(n, l.direction)) + SampleSH(n)); return half4(c, 1); } ENDHLSL } Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; half fade : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L float3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; float3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting o.positionCS = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 o.fade = saturate(1.0 - height * _ShadowFalloff); return o; } half4 frag(Varyings i) : SV_Target { return half4(_ShadowColor.rgb, _ShadowColor.a * i.fade); } ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex Ch23DepthOnlyVert #pragma fragment Ch23NullFrag #include "Assets/Cookbook/Ch23/Ch23Common.hlsl" ENDHLSL } } }
// 案例 23.3:平面投影阴影(Planar Shadow)(GLSL 版)。 // 第一个 Pass 正常着色;第二个 Pass(SRPDefaultUnlit)把同一个网格沿主光方向“拍扁”到地面上, // 输出半透明的阴影色,并用模板测试保证同一个像素只被阴影覆盖一次,避免重叠部分变黑。 Shader "Cookbook/Ch23/PlanarShadow" { SubShader { // 排在普通不透明物体之后,保证地面已经画好,阴影才能混合到地面颜色上 Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry+10" } GLSLINCLUDE uniform vec4 _BaseColor; uniform vec4 _ShadowColor; uniform float _PlaneHeight; uniform float _ShadowFalloff; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM VARYING vec3 normalWS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT void main() { vec3 n = normalize(normalWS); Light l = GetMainLight(); vec3 c = _BaseColor.rgb * (l.color * saturate(dot(n, l.direction)) + SampleSH(n)); fragColor = vec4(c, 1); } #endif ENDGLSL } Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back GLSLPROGRAM VARYING float fade; #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L vec3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; vec3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting gl_Position = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 fade = saturate(1.0 - height * _ShadowFalloff); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(_ShadowColor.rgb, _ShadowColor.a * fade); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define CH23_DEPTH_PASS #include "Ch23/Ch23Common.glsl" ENDGLSL } } }
为什么用 SRPDefaultUnlit。URP 的不透明绘制会同时收集 SRPDefaultUnlit、UniversalForward、UniversalForwardOnly 三种 LightMode 的 Pass。一个 Shader 里同时有 UniversalForward 和 SRPDefaultUnlit 两个 Pass,物体就会被画两遍——这是 URP 里实现“多 Pass 效果”(描边、平面阴影)最简单的方式,代价是这两个 Pass 不能被 SRP Batcher 合并成一次绘制。如果项目里平面阴影物体很多,也可以把阴影 Pass 单独做成一个 LightMode(如 "PlanarShadow"),再用 RenderObjects Renderer Feature 在不透明物体之后统一绘制,第 25 章会讲 RenderObjects 的用法。
Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; half fade : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L float3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; float3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting o.positionCS = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 o.fade = saturate(1.0 - height * _ShadowFalloff); return o; } half4 frag(Varyings i) : SV_Target { return half4(_ShadowColor.rgb, _ShadowColor.a * i.fade); } ENDHLSL }
Pass { Name "PlanarShadow" Tags { "LightMode"="SRPDefaultUnlit" } // URP 会把它和 UniversalForward 一起画 Stencil { Ref 1 Comp [_StencilComp] // NotEqual:这个像素还没画过阴影才画 Pass Replace // 画过就把模板写成 1,后面的阴影三角形再来就被拒绝 } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back GLSLPROGRAM VARYING float fade; #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); // _MainLightPosition.xyz 对方向光来说是“指向光源”的单位向量 L vec3 L = normalize(_MainLightPosition.xyz); // 求沿 -L 方向走多远能碰到平面 y = h: P' = P - L * (P.y - h) / L.y float height = positionWS.y - _PlaneHeight; vec3 projected = positionWS - L * (height / max(L.y, 1e-3)); projected.y = _PlaneHeight + 0.002; // 抬高 2 毫米,避免与地面 Z-Fighting gl_Position = TransformWorldToHClip(projected); // 离地越高的部位投下的阴影越淡,模拟真实阴影随距离变软变淡 fade = saturate(1.0 - height * _ShadowFalloff); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(_ShadowColor.rgb, _ShadowColor.a * fade); } #endif ENDGLSL }
为什么要模板测试。阴影是半透明混合,同一个像素被两层三角形覆盖就会变成 1−(1−a)² 的浓度。角色的身体和手臂、两个角色之间,投影都会重叠。模板设置 Ref 1 / Comp NotEqual / Pass Replace 的含义是:模板缓冲里这个像素还不是 1 才允许画,画完立刻写成 1,于是后来的阴影三角形全部被拒绝。所有角色共用 Ref 1,角色之间的重叠也一并消除。
为什么 Queue 是 Geometry+10。阴影 Pass 不写深度、要和地面颜色混合,所以必须在地面画完之后再画。不透明队列内部是按距离从前往后排序的,地面这种大物体的排序位置不可预测,干脆把角色材质的队列推后一点,保证地面先画。
高度淡出。fade = saturate(1 − height × _ShadowFalloff) 让离地越高的部位投下的影子越淡,模拟真实阴影随距离变虚。片元透明度与顶点高度相关,同一块阴影内部会有渐变。


常见坑:一是光线接近水平时 L.y 趋于 0,投影被拉到无穷远,代码里用 max(L.y, 1e-3) 防止除零,项目里最好再限制最大投影长度。二是投影出的三角形落在地面高度,必须轻微抬高(这里是 2 毫米)或用 Offset 避免 Z-Fighting。三是不平整的地面(坡道、台阶)不适用,可以只在角色脚下的局部地面取一个平均法线和高度,用通用的 n·p + d = 0 版本矩阵。四是 URP 的 Deferred 路径会使用模板缓冲的高位,用平面阴影时注意不要和它的模板位冲突(可以设置 ReadMask/WriteMask 只用低位)。