案例 23.7用 Renderer Feature 实现接触阴影高阶
目标:仿照 URP 自带的 Screen Space Shadows 的结构,写一个 Renderer Feature,在深度预渲染之后生成一张全屏的接触阴影纹理 _ContactShadowTexture;接收物体的 Shader 用屏幕 UV 读它,和阴影贴图的结果取最小值。Renderer Feature 的完整生命周期在第 25 章详细讲,这里关注阴影本身。
class ContactShadowPass : ScriptableRenderPass { static readonly int s_Params = Shader.PropertyToID("_ContactParams"); static readonly int s_LightDir = Shader.PropertyToID("_ContactLightDir"); readonly ProfilingSampler m_Sampler = new ProfilingSampler("ContactShadows"); RTHandle m_Target; Material m_Material; Settings m_Settings; Vector3 m_LightDir; public void Setup(Settings s, Material material, Vector3 lightDir) { m_Settings = s; m_Material = material; m_LightDir = lightDir; // 声明需要深度纹理:URP 会因此安排 DepthPrepass,保证此时 _CameraDepthTexture 已就绪 ConfigureInput(ScriptableRenderPassInput.Depth); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var desc = renderingData.cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.msaaSamples = 1; desc.graphicsFormat = RenderingUtils.SupportsGraphicsFormat(GraphicsFormat.R8_UNorm, FormatUsage.Linear | FormatUsage.Render) ? GraphicsFormat.R8_UNorm : GraphicsFormat.B8G8R8A8_UNorm; RenderingUtils.ReAllocateIfNeeded(ref m_Target, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_ContactShadowTexture"); cmd.SetGlobalTexture(m_Target.name, m_Target.nameID); ConfigureTarget(m_Target); ConfigureClear(ClearFlag.None, Color.white); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { // URP 14 里 renderingData.commandBuffer 是 internal,自己的 Feature 从池里借一个 var cmd = CommandBufferPool.Get(); using (new ProfilingScope(cmd, m_Sampler)) { m_Material.SetVector(s_Params, new Vector4(m_Settings.length, m_Settings.steps, m_Settings.thickness, m_Settings.bias)); m_Material.SetVector(s_LightDir, new Vector4(m_LightDir.x, m_LightDir.y, m_LightDir.z, m_Settings.intensity)); // 源和目标是同一张图:Shader 根本不读 _BlitTexture,只借 Blitter 画一个全屏三角形 Blitter.BlitCameraTexture(cmd, m_Target, m_Target, m_Material, 0); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public void Dispose() => m_Target?.Release(); }
// 案例 23.9:接触阴影(Contact Shadows)的网页版(对应 Ch23/ContactShadowFeature.cs)。 // 在深度预渲染之后,用一个全屏 Pass 从每个像素出发、沿主光方向在屏幕空间里步进几步, // 检查有没有被深度缓冲里更靠前的表面挡住,结果写进一张 R8 的全屏纹理 _ContactShadowTexture。 // 接收物体的 Shader 再用屏幕 UV 读它,与阴影贴图的结果取最小值。 // // 网页引擎没有 URP 那样“声明 ConfigureInput(Depth) 就自动安排 DepthPrepass”的机制, // 所以这里自己先画一遍 DepthOnly,得到此时的 _CameraDepthTexture。 import { Material } from '../engine/shader.js'; import { RenderPassEvent } from '../engine/features.js'; export default class ContactShadowFeature { constructor(engine, data, scene) { this.engine = engine; this.scene = scene; const s = (data && data.settings) || {}; this.settings = { length: s.length ?? 0.35, steps: s.steps ?? 16, thickness: s.thickness ?? 0.15, bias: s.bias ?? 0.01, intensity: s.intensity ?? 1 }; } async init() { this.material = new Material(await this.engine.shaders.get('Cookbook/Ch23/ContactShadowPass')); } addPasses(frame, enqueue) { const L = frame.mainLight; if (!L) return; // 方向光的 forward 是光线前进方向,取反得到“指向光源”的 L const dir = [-L.forward[0], -L.forward[1], -L.forward[2]]; // 与 URP 的屏幕空间阴影相同的时机:深度预渲染之后、不透明物体之前 enqueue(RenderPassEvent.AfterRenderingPrePasses + 1, (fr) => this.execute(fr, dir)); } execute(fr, L) { const gl = fr.gl, s = this.settings, W = fr.width, H = fr.height; // 1) 深度预渲染(URP 的 DepthPrepass) const depthRT = fr.getTemporaryRT('ContactDepthPrepass', W, H, { color: 'R8', depth: 'DEPTH24' }); this.engine.bindTarget(depthRT); gl.depthMask(true); gl.colorMask(true, true, true, true); gl.clearColor(1, 1, 1, 1); gl.clearDepth(1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); fr.drawRenderers({ queue: 'opaque', lightModes: ['DepthOnly'] }); fr.setGlobal('_CameraDepthTexture', depthRT.depthTexture); // 2) 全屏步进,写 _ContactShadowTexture const target = fr.getTemporaryRT('_ContactShadowTexture', W, H, { color: 'R8' }); this.material.setVector('_ContactParams', [s.length, s.steps, s.thickness, s.bias]); this.material.setVector('_ContactLightDir', [L[0], L[1], L[2], s.intensity]); fr.blit(null, target, this.material, 0); fr.setGlobal('_ContactShadowTexture', target.texture); fr.bindCamera(); } }
几个要点:ConfigureInput(ScriptableRenderPassInput.Depth) 声明这个 Pass 需要深度纹理,并且事件早于不透明物体,URP 会因此安排一次深度预渲染,保证 Pass 执行时 _CameraDepthTexture 已经是本帧的深度。OnCameraSetup 里用 RenderingUtils.ReAllocateIfNeeded 按相机尺寸分配 R8 纹理并设为全局纹理。URP 14 里 RenderingData.commandBuffer 是 internal 的,自己的 Feature 要从 CommandBufferPool 借命令缓冲。主光方向从 visibleLights[mainLightIndex].localToWorldMatrix 的第三列取:方向光的 forward 是光线前进方向,取反就是“指向光源”的 L。
half Frag(Varyings input) : SV_Target { float2 uv = input.texcoord; float deviceDepth = SampleSceneDepth(uv); #if UNITY_REVERSED_Z if (deviceDepth <= 0.0) return 1; // 天空:不处理 #else if (deviceDepth >= 1.0) return 1; deviceDepth = deviceDepth * 2.0 - 1.0; #endif // 1) 由深度重建这个像素的世界坐标 float3 positionWS = ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP); int steps = (int)_ContactParams.y; float stepLen = _ContactParams.x / _ContactParams.y; float thickness = _ContactParams.z; float3 L = _ContactLightDir.xyz; // 2) 用交错梯度噪声抖动起点,把条带状走样变成细噪点(软阴影 / TAA 会再把它抹平) float jitter = InterleavedGradientNoise(uv * _ScreenSize.xy, 0); float3 p = positionWS + L * (_ContactParams.w + stepLen * jitter); half occlusion = 0; [loop] for (int i = 0; i < steps; i++) { p += L * stepLen; // 3) 把采样点投回屏幕,读那里的场景深度 float4 positionCS = TransformWorldToHClip(p); float2 sampleUV = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP); if (any(sampleUV < 0) || any(sampleUV > 1)) break; float sceneEyeDepth = LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams); float sampleEyeDepth = positionCS.w; // 透视投影下 w 就是观察空间深度 float delta = sampleEyeDepth - sceneEyeDepth; // > 0:采样点在可见表面之后 // 4) 在表面之后、但又没“穿透”太深 → 光线被这块表面挡住了 if (delta > 0.002 && delta < thickness) { // 离起点越远的遮挡越弱,避免接触阴影末端出现硬边 occlusion = 1.0 - (float)i / steps; break; } } return 1.0 - occlusion * _ContactLightDir.w; }
float Frag() { vec2 uv = texcoord; float deviceDepth = SampleSceneDepth(uv); // WebGL 深度不反转:远平面是 1 if (deviceDepth >= 1.0) return 1.0; // 天空:不处理 // 1) 由深度重建这个像素的世界坐标(GLSL 版 ComputeWorldSpacePosition 内部已把 0~1 深度换成 -1~1) vec3 positionWS = ComputeWorldSpacePosition(uv, deviceDepth, UNITY_MATRIX_I_VP); int steps = int(_ContactParams.y); float stepLen = _ContactParams.x / _ContactParams.y; float thickness = _ContactParams.z; vec3 L = _ContactLightDir.xyz; // 2) 用交错梯度噪声抖动起点,把条带状走样变成细噪点(软阴影 / TAA 会再把它抹平) float jitter = InterleavedGradientNoise(uv * _ScreenParams.xy, 0); vec3 p = positionWS + L * (_ContactParams.w + stepLen * jitter); float occlusion = 0.0; for (int i = 0; i < steps; i++) { p += L * stepLen; // 3) 把采样点投回屏幕,读那里的场景深度 vec4 positionCS = TransformWorldToHClip(p); vec2 sampleUV = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP); if (any(lessThan(sampleUV, vec2(0.0))) || any(greaterThan(sampleUV, vec2(1.0)))) break; float sceneEyeDepth = LinearEyeDepth(SampleSceneDepth(sampleUV), _ZBufferParams); float sampleEyeDepth = positionCS.w; // 透视投影下 w 就是观察空间深度 float delta = sampleEyeDepth - sceneEyeDepth; // > 0:采样点在可见表面之后 // 4) 在表面之后、但又没“穿透”太深 → 光线被这块表面挡住了 if (delta > 0.002 && delta < thickness) { // 离起点越远的遮挡越弱,避免接触阴影末端出现硬边 occlusion = 1.0 - float(i) / float(steps); break; } } return 1.0 - occlusion * _ContactLightDir.w; }
步进的每一步把世界坐标投回屏幕:ComputeNormalizedDeviceCoordinates 内部处理了 UNITY_UV_STARTS_AT_TOP 的翻转,与重建世界坐标用的 ComputeWorldSpacePosition 互为逆运算。比较深度用的是观察空间线性深度:采样点的深度直接取裁剪坐标的 w(透视投影下 w 就是观察空间深度),场景深度用 LinearEyeDepth 从深度缓冲换算。“厚度”参数是对深度缓冲局限的妥协:深度缓冲只知道最前面那层表面,不知道它有多厚,采样点在表面后方太远时多半是从物体背后穿过,不应算遮挡。起点用交错梯度噪声(Interleaved Gradient Noise)抖动,把步进造成的条带变成细噪点;遮挡强度随步数衰减,避免接触阴影末端出现一道硬边。
half4 frag(Varyings i) : SV_Target { half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb; half3 n = normalize(i.normalWS); Light l = GetMainLight(TransformWorldToShadowCoord(i.positionWS), i.positionWS, half4(1, 1, 1, 1)); half contact = 1; if (_UseContact > 0.5) { float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS); contact = SAMPLE_TEXTURE2D(_ContactShadowTexture, sampler_LinearClamp, screenUV).r; } if (_ShowContact > 0.5) return half4(contact.xxx, 1); half shadow = min(l.shadowAttenuation, contact); half3 c = albedo * (l.color * saturate(dot(n, l.direction)) * shadow + SampleSH(n)); return half4(c, 1); }
vec4 frag() { vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb; vec3 n = normalize(normalWS); Light l = GetMainLight(TransformWorldToShadowCoord(positionWS), positionWS, vec4(1, 1, 1, 1)); float contact = 1.0; if (_UseContact > 0.5) { vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord); contact = texture(_ContactShadowTexture, screenUV).r; } if (_ShowContact > 0.5) return vec4(vec3(contact), 1); float shadow = min(l.shadowAttenuation, contact); vec3 c = albedo * (l.color * saturate(dot(n, l.direction)) * shadow + SampleSH(n)); return vec4(c, 1); }

局限与调参:接触阴影只能看到屏幕上可见的表面,屏幕边缘外和被遮住的物体不会投影;物体背后(深度缓冲看不到的那一面)用厚度猜测,厚度太大会出现“影子拖尾”,太小会让薄物体漏光。追踪长度一般控制在几十厘米以内,只用来补阴影贴图的近距离细节。性能上,它是每像素 N 次深度采样的全屏 Pass,步数 16~24 在桌面端开销不大;移动端可以降到 8 步,或者用半分辨率渲染再双线性放大。HDRP 内置了更完整的 Contact Shadows(还支持附加光),URP 14 没有内置,本案例提供的是一个可以直接用的起点。