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,或通过脚本强制开启:

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 分布),必须用 LinearEyeDepthLinear01Depth 转换后再做世界坐标还原。

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 导致法线抖动

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

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

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;
相关推荐
空中海2 小时前
第五篇:Unity工程化能力
elasticsearch·unity·游戏引擎
LF男男2 小时前
TouchPad(单例)
unity·c#
天人合一peng2 小时前
Unity 3D 电脑端和手机端都实现画线与清除功能
3d·unity·智能手机
云上空2 小时前
Unity 角色“防卡墙”实战:不用动态物理材质,也能稳定解决 Wedging 问题
unity·游戏引擎·材质
不绝19113 小时前
导航系统/NavMeshAgent组件
unity
mxwin16 小时前
Unity Shader 屏幕空间 UVScreen Space UV 完全指南
unity·游戏引擎·uv
LF男男19 小时前
TouchManager
unity·c#
mxwin20 小时前
Unity Shader 径向模糊与径向 UV 变形速度感 · 冲击波效果完全指南
unity·游戏引擎·shader·uv
weixin_4239950020 小时前
unity 微信开发小游戏,网络资源获取数据
unity·游戏引擎