案例 22.5由深度重建法线与角度衰减进阶
目标:知道每个像素所在表面的朝向,在侧面上淡出贴花。
屏幕空间贴花拿不到网格的法线,只能从深度推算。最简单的方法利用片元着色器的屏幕空间导数:ddx(positionWS) 和 ddy(positionWS) 是相邻像素之间世界坐标的差,都位于表面的切平面内,它们的叉积就是表面法线。
// 3) 用屏幕空间导数重建表面法线(平面法线,物体轮廓处会有噪点) float3 normalWS = normalize(cross(ddx(positionWS), ddy(positionWS))); normalWS *= sign(dot(normalWS, GetWorldSpaceViewDir(positionWS))); // 统一朝向相机,免去平台 Y 方向差异 // 4) 角度衰减:表面越“侧”对投影方向,贴花越淡,避免在侧壁上拉出长条 float3 projDirWS = normalize(TransformObjectToWorldDir(float3(0, 0, 1))); float facing = dot(normalWS, -projDirWS); // 1 = 正对投影器 float cosStart = cos(radians(_AngleStart)), cosEnd = cos(radians(_AngleEnd)); half angleFade = saturate((facing - cosEnd) / max(cosStart - cosEnd, 1e-4)); // 盒子前后两端也淡出,贴花不会在投影深度的尽头被一刀切断 half depthFade = saturate((0.5 - abs(positionOS.z)) / _DepthFade);
// 3) 用屏幕空间导数重建表面法线(平面法线,物体轮廓处会有噪点) vec3 normalWS = normalize(cross(dFdx(positionWS), dFdy(positionWS))); normalWS *= sign(dot(normalWS, GetWorldSpaceViewDir(positionWS))); // 统一朝向相机,免去平台 Y 方向差异 // 4) 角度衰减:表面越“侧”对投影方向,贴花越淡,避免在侧壁上拉出长条 vec3 projDirWS = normalize(TransformObjectToWorldDir(vec3(0, 0, 1))); float facing = dot(normalWS, -projDirWS); // 1 = 正对投影器 float cosStart = cos(radians(_AngleStart)), cosEnd = cos(radians(_AngleEnd)); float angleFade = saturate((facing - cosEnd) / max(cosStart - cosEnd, 1e-4)); // 盒子前后两端也淡出,贴花不会在投影深度的尽头被一刀切断 float depthFade = saturate((0.5 - abs(positionOS.z)) / _DepthFade);
叉积的方向取决于屏幕 Y 轴朝上还是朝下(各图形 API 不同),所以最后用“法线应朝向相机”这一事实统一符号。角度衰减比较的是重建法线与“逆投影方向”的点积(即夹角余弦),在 _AngleStart 到 _AngleEnd 之间从 1 降到 0。为了与 Inspector 上的角度直觉一致,参数用角度表示,Shader 里转成余弦再比较——两次 cos(radians()) 都只依赖材质常量,编译器会把它们折叠或至少只在一处计算;对性能敏感时也可以像 URP 一样在 C# 里预先算好。

导数法线的两个缺陷在左图里都能看到。第一,它是平面法线:深度图里存的是光栅化后的三角形,导数得到的是三角形本身的朝向,不是网格的平滑插值法线,所以球面看起来是多面体。用它做角度衰减没问题,用它做光照则会暴露网格的面片感。第二,轮廓处有噪点:GPU 按 2×2 像素块求导,块里如果同时有前景和背景两个表面,算出的“切线”跨越了深度断层,法线完全错误。改进方法有两种:一是用多次深度采样挑选更可信的邻居(URP 屏幕空间贴花的 Normal Blend Medium/High 就是 5 次和 9 次采样的版本,实现在 ShaderLibrary/NormalReconstruction.hlsl);二是直接读取 _CameraNormalsTexture(DeclareNormalsTexture.hlsl 里的 SampleSceneNormals),前提是本帧有 DepthNormals 预渲染——例如开启了 SSAO,或者某个 Renderer Feature 用 ConfigureInput(ScriptableRenderPassInput.Normal) 请求了法线。