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,或通过脚本强制开启:
cs
UniversalRenderPipeline.asset.renderScale = 1.0f;
GraphicsSettings.useScriptableRenderPipeline = true;
// 在 Renderer Feature 或 Camera 中开启
camera.GetComponent<UniversalAdditionalCameraData>()
.renderPostProcessing = true;
// URP 14+ 会自动生成 _CameraDepthTexture
3.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。
cs
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
cs
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)
用左右差分和上下差分代替单侧差分,法线更对称:
cs
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 深度阈值过滤
当相邻像素深度差过大(跨越物体边界),叉积结果无意义。用阈值钳制:
cs
// 线性化后做差
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:
cs
// 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) 方向相反。如果法线指向表面内部,取反即可:
cs
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),如果无法改模型:
cs
// 用上一帧法线做 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 像素步长做差分,增大世界空间距离
cs
float eyeZ = LinearEyeDepth(d0, _ZBufferParams);
// 远处用更大步长
float stepScale = saturate(eyeZ / 100.0) * 2.0 + 1.0;
float2 delta = stepScale / _ScreenParams.xy;