Unity Shader 屏幕空间法线重建 从深度缓冲反推世界法线——原理、踩坑与 URP Shader 实战
01为什么需要屏幕空间法线在 Unity URP 管线下_CameraNormalsTexture并非默认开启。如果后处理效果SSAO、SSR、屏幕空间贴花、轮廓描边等需要法线信息你有两条路方案开销精度改动量开启Depth Normals Prepass多一次全屏 Pass精确顶点法线仅改 Renderer 配置从深度缓冲重建法线0 额外 Pass近似面法线写 Shader 代码对于移动端或已有深度纹理的项目重建法线是零额外 Pass 的选择——代价是得到的是面法线而非顶点法线但这对 SSAO、贴花等用途已经足够。 何时选重建目标平台移动端、已开启深度纹理、不需要顶点法线的平滑过渡、可接受面法线的棱角感。如果需要精确法线直接开 Prepass 更省心。02核心原理从深度到法线思路很简单在屏幕空间取相邻像素的深度还原出它们的世界坐标再做叉积得到法线。2.1 深度 → 世界坐标给定像素(x, y)和深度d先构造 NDC 坐标再通过逆投影矩阵映射到世界空间Pworld ComputeWorldSpacePosition(UV, depth,UNITY_MATRIX_I_VP)URP 提供了ComputeWorldSpacePosition工具函数内部流程2.2 世界坐标 → 法线取当前像素与右邻、下邻的世界坐标差叉积归一化N normalize(cross(Pright − Pcenter, Pbottom − Pcenter))⚠️ 叉积方向cross(right, bottom)在 URP 的左手坐标系下指向表面外侧。如果用cross(bottom, right)会得到反向法线SSAO 等效果会反掉。03URP 中的深度缓冲在动手写 Shader 之前必须确认深度纹理可用且格式正确。3.1 开启深度纹理在 URP Renderer Data 中勾选Depth Texture或通过脚本强制开启UniversalRenderPipeline.asset.renderScale 1.0f; GraphicsSettings.useScriptableRenderPipeline true; // 在 Renderer Feature 或 Camera 中开启 camera.GetComponentUniversalAdditionalCameraData() .renderPostProcessing true; // URP 14 会自动生成 _CameraDepthTexture3.2 深度值的编码格式格式精度Shader 采样说明_CameraDepthTexture24-bit Z-bufferSAMPLE_DEPTH_TEXTURE默认深度纹理非线性_CameraDepthAttachmentFloat / R32LOAD_TEXTURE2D_XURP 14 延迟路径 线性化从_CameraDepthTexture读出的原始值是非线性的1/z 分布必须用LinearEyeDepth或Linear01Depth转换后再做世界坐标还原。04Shader 实现完整代码以下是一个可直接用于 URP 后处理的完整 Shader输出重建的屏幕空间法线。可挂到Fullscreen Shader Graph或自定义ScriptableRendererFeature。3.2 深度值的编码格式 格式 精度 Shader 采样 说明 _CameraDepthTexture 24-bit Z-buffer SAMPLE_DEPTH_TEXTURE 默认深度纹理非线性 _CameraDepthAttachment Float / R32 LOAD_TEXTURE2D_X URP 14 延迟路径 线性化 从 _CameraDepthTexture 读出的原始值是非线性的1/z 分布必须用 LinearEyeDepth 或 Linear01Depth 转换后再做世界坐标还原。 04 Shader 实现完整代码 以下是一个可直接用于 URP 后处理的完整 Shader输出重建的屏幕空间法线。可挂到 Fullscreen Shader Graph 或自定义 ScriptableRendererFeature。 HLSL ScreenSpaceNormals.shader — 重建核心 #ifndef SCREEN_SPACE_NORMALS_INCLUDED #define SCREEN_SPACE_NORMALS_INCLUDED #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl // ─── 从深度UV 还原世界坐标 ─── float3 ReconstructWorldPos(float2 uv, float rawDepth) { // UV → NDC ([-1,1]) float2 ndc uv * 2.0 - 1.0; // 构造 NDC 齐次坐标 float4 hcs float4(ndc, rawDepth, 1.0); // 逆 VP 变换 float4 wp mul(UNITY_MATRIX_I_VP, hcs); return wp.xyz / wp.w; } // ─── 屏幕空间法线重建 ─── float3 ReconstructScreenSpaceNormal(float2 uv) { // 单像素 UV 偏移 float2 delta 1.0 / _ScreenParams.xy; // 采样三处深度中心、右邻、下邻 float d0 SampleSceneDepth(uv); float dR SampleSceneDepth(uv float2(delta.x, 0)); float dB SampleSceneDepth(uv float2(0, delta.y)); // 还原世界坐标 float3 P0 ReconstructWorldPos(uv, d0); float3 PR ReconstructWorldPos(uv float2(delta.x,0), dR); float3 PB ReconstructWorldPos(uv float2(0,delta.y), dB); // 叉积求法线左手坐标系right × bottom → 表面外 float3 n normalize(cross(PR - P0, PB - P0)); return n; } #endiffloat4 MyFragment(Varyings input) : SV_Target { float3 normal ReconstructScreenSpaceNormal(input.uv); // [-1,1] → [0,1] 用于可视化 return float4(normal * 0.5 0.5, 1.0); }05采样模式与边缘处理简单的 3-tap 采样中心右下在物体边缘会产生法线断裂。以下三种改进策略按复杂度递增5.1 居中差分6-tap用左右差分和上下差分代替单侧差分法线更对称float dL SampleSceneDepth(uv - float2(delta.x, 0)); float dR SampleSceneDepth(uv float2(delta.x, 0)); float dT SampleSceneDepth(uv - float2(0, delta.y)); float dB SampleSceneDepth(uv float2(0, delta.y)); float3 PL ReconstructWorldPos(uv - float2(delta.x,0), dL); float3 PR ReconstructWorldPos(uv float2(delta.x,0), dR); float3 PT ReconstructWorldPos(uv - float2(0,delta.y), dT); float3 PB ReconstructWorldPos(uv float2(0,delta.y), dB); float3 n normalize(cross(PR - PL, PB - PT));5.2 深度阈值过滤当相邻像素深度差过大跨越物体边界叉积结果无意义。用阈值钳制// 线性化后做差 float linearD0 LinearEyeDepth(d0, _ZBufferParams); float linearDR LinearEyeDepth(dR, _ZBufferParams); // 深度差超过阈值 → 视为边缘弃用该方向 float threshold 0.01 * linearD0; // 距离自适应 if (abs(linearDR - linearD0) threshold) PR P0; // 退回中心叉积归零5.3 Sobel 十字采样9-tap取上下左右四角的深度加权归并后求法线对边缘更鲁棒 实用建议移动端用 3-tap 就够了PC/主机推荐 6-tap 深度阈值。9-tap 仅在极端边缘场景下有明显优势代价是多 3 次纹理采样。06性能分析与优化6.1 开销拆解步骤3-tap6-tap9-tap深度采样348矩阵乘mul(IVP, v)348叉积 归一化111总 ALU 指令约~45~60~1206.2 关键优化① 在视图空间中计算避免每次调用mul(UNITY_MATRIX_I_VP)可以只做一次逆投影在 View Space 叉积后再转回 World Space// View space 差分 → 直接用 LinearEyeDepth 构造 float eyeZ LinearEyeDepth(rawDepth, _ZBufferParams); float3 viewPos ReconstructViewPos(uv, eyeZ); // 叉积后旋转回世界空间 float3 worldN mul((float3x3)UNITY_MATRIX_I_V, viewN);② 降低分辨率对 SSAO 等不需要全分辨率的效果在 1/2 或 1/4 分辨率的 RT 上重建法线采样次数不变但像素数降为 1/4~1/16。③ 利用_CameraDepthTexture的硬件采样将深度纹理的Filter Mode设为Point默认避免双线性插值引入错误深度值。 URP 14 如果使用Depth Prepass深度纹理已经是 Point 采样。07常见问题与排查Q1法线可视化全是粉红色 / 偏色检查叉积方向。cross(PR-P0, PB-P0)和cross(PB-P0, PR-P0)方向相反。如果法线指向表面内部取反即可float3 n normalize(cross(PR - P0, PB - P0)); if (dot(n, _WorldSpaceCameraPos - P0) 0) n -n; // 确保朝向相机Q2物体边缘出现亮线 / 黑线这是深度不连续导致的法线断裂。解决方案使用 5.2 节的深度阈值过滤对法线做一次 3×3 高斯模糊仅对边缘像素如果后处理支持用discard跳过边缘像素Q3Z-fighting 导致法线抖动两个重叠面争夺同一像素深度每帧深度值在两个面之间跳动。根本方案是消除重叠面调整Offset或Stencil如果无法改模型// 用上一帧法线做 blend减少闪烁 float3 prevN SAMPLE_TEXTURE2D_X(_PrevFrameNormals, sampler, uv).xyz; float3 n normalize(lerp(prevN * 2.0 - 1.0, currN, 0.2));Q4UNITY_MATRIX_I_VP和相机抖动TAA开启 TAA 后 URP 会对投影矩阵施加亚像素抖动。UNITY_MATRIX_I_VP已包含抖动但如果你的 Pass 在 TAA 之前执行需要手动去除抖动分量否则法线会产生亚像素噪声。⚠️ XR / 多目相机在 XR 下_ScreenParams可能返回单眼分辨率。用GetScaledScreenParams()替代确保 UV 偏移在正确的分辨率下计算。Q5远平面法线精度崩了深度缓冲的 1/z 分布导致远处精度不足。远处两个相邻像素的世界坐标差可能极小叉积结果接近零向量。缓解方法用Reversed-ZURP 默认开启近处精度更高缩短远裁面距离在远距离处用 2×2 像素步长做差分增大世界空间距离float eyeZ LinearEyeDepth(d0, _ZBufferParams); // 远处用更大步长 float stepScale saturate(eyeZ / 100.0) * 2.0 1.0; float2 delta stepScale / _ScreenParams.xy;