Unity Shader 屏幕空间法线重建 从深度缓冲反推世界法线——原理、踩坑与 URP Shader 实战
2026/4/21 21:19:56 网站建设 项目流程

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.GetComponent<UniversalAdditionalCameraData>() .renderPostProcessing = true; // URP 14+ 会自动生成 _CameraDepthTexture

3.2 深度值的编码格式

格式精度Shader 采样说明
_CameraDepthTexture24-bit Z-bufferSAMPLE_DEPTH_TEXTURE默认深度纹理,非线性
_CameraDepthAttachmentFloat / R32LOAD_TEXTURE2D_XURP 14 延迟路径

💡 线性化_CameraDepthTexture读出的原始值是非线性的(1/z 分布),必须用LinearEyeDepthLinear01Depth转换后再做世界坐标还原。

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; } #endif
float4 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~120

6.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跳过边缘像素

Q3:Z-fighting 导致法线抖动

两个重叠面争夺同一像素深度,每帧深度值在两个面之间跳动。

根本方案是消除重叠面(调整OffsetStencil),如果无法改模型:

// 用上一帧法线做 blend,减少闪烁 float3 prevN = SAMPLE_TEXTURE2D_X(_PrevFrameNormals, sampler, uv).xyz; float3 n = normalize(lerp(prevN * 2.0 - 1.0, currN, 0.2));

Q4:UNITY_MATRIX_I_VP和相机抖动(TAA)

开启 TAA 后 URP 会对投影矩阵施加亚像素抖动。UNITY_MATRIX_I_VP已包含抖动,但如果你的 Pass 在 TAA 之前执行,需要手动去除抖动分量,否则法线会产生亚像素噪声。

⚠️ XR / 多目相机在 XR 下_ScreenParams可能返回单眼分辨率。用GetScaledScreenParams()替代,确保 UV 偏移在正确的分辨率下计算。

Q5:远平面法线精度崩了

深度缓冲的 1/z 分布导致远处精度不足。远处两个相邻像素的世界坐标差可能极小,叉积结果接近零向量。

缓解方法:

  • Reversed-Z(URP 默认开启),近处精度更高
  • 缩短远裁面距离
  • 在远距离处用 2×2 像素步长做差分,增大世界空间距离
float eyeZ = LinearEyeDepth(d0, _ZBufferParams); // 远处用更大步长 float stepScale = saturate(eyeZ / 100.0) * 2.0 + 1.0; float2 delta = stepScale / _ScreenParams.xy;

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询