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+ 会自动生成 _CameraDepthTexture3.2 深度值的编码格式
| 格式 | 精度 | Shader 采样 | 说明 |
|---|---|---|---|
_CameraDepthTexture | 24-bit Z-buffer | SAMPLE_DEPTH_TEXTURE | 默认深度纹理,非线性 |
_CameraDepthAttachment | Float / R32 | LOAD_TEXTURE2D_X | URP 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-tap | 6-tap | 9-tap |
|---|---|---|---|
| 深度采样 | 3 | 4 | 8 |
矩阵乘(mul(IVP, v)) | 3 | 4 | 8 |
| 叉积 + 归一化 | 1 | 1 | 1 |
| 总 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 导致法线抖动
两个重叠面争夺同一像素深度,每帧深度值在两个面之间跳动。
根本方案是消除重叠面(调整Offset或Stencil),如果无法改模型:
// 用上一帧法线做 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;