GameTech/URP 渲染实战

案例 22.4屏幕空间贴花:深度重建与盒内裁剪进阶

目标:实现上面五步,得到一个可以贴在任意几何体上的贴花。

渲染状态

Assets/Cookbook/Ch22/ScreenSpaceDecal.shader(节选:state)
Cull Front        // 画盒子的“背面”:相机钻进盒子里也不会丢
ZTest Greater     // 背面比场景表面“更远”才通过 = 场景表面在盒子里(或盒子前方)
ZWrite Off
Blend [_SrcBlend] [_DstBlend]
Cull Front        // 画盒子的“背面”:相机钻进盒子里也不会丢
ZTest Greater     // 背面比场景表面“更远”才通过 = 场景表面在盒子里(或盒子前方)
ZWrite Off
Blend [_SrcBlend] [_DstBlend]

为什么画背面、用 ZTest Greater?画正面看似更自然,但相机一旦进入盒子(贴花很大、或者贴在相机脚下的地面上),正面全在相机后方被裁掉,贴花瞬间消失。背面则只要盒子还在视野里就一定有片元。ZTest Greater 的意思是“盒子背面比深度缓冲里的场景表面更远时才通过”——也就是场景表面在背面的前方,它要么在盒子里、要么在盒子前面(后者会被第 4 步的范围判断丢弃)。这样盒子后面完全没有表面的像素(比如天空)连片元着色器都不会执行,省掉了不少开销。Unity 会根据平台是否使用 Reversed-Z 自动翻转比较方向,Shader 里按“Greater = 更远”写即可。

队列设为 Transparent-100:在不透明物体之后(此时深度图已经生成),在普通透明物体之前(贴花不应该出现在玻璃、粒子上面)。

重建世界坐标

Assets/Cookbook/Ch22/ScreenSpaceDecal.shader(节选:reconstruct)
    // 1) 屏幕 UV → 场景深度 → 世界坐标
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
    float rawDepth = SampleSceneDepth(screenUV);
#if !UNITY_REVERSED_Z
    rawDepth = lerp(UNITY_NEAR_CLIP_VALUE, 1, rawDepth);   // OpenGL 类平台:[0,1] → [-1,1]
#endif
    float3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP);

    // 2) 世界坐标 → 贴花盒子的本地空间;盒子外的像素直接丢掉
    float3 positionOS = TransformWorldToObject(positionWS);
    clip(0.5 - abs(positionOS));
// 1) 屏幕 UV → 场景深度 → 世界坐标
vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
float rawDepth = SampleSceneDepth(screenUV);
// HLSL 版在 OpenGL 类平台要先把 [0,1] 深度换成 [-1,1];GLSL 版的 ComputeWorldSpacePosition 内部已经做了
vec3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP);

// 2) 世界坐标 → 贴花盒子的本地空间;盒子外的像素直接丢掉
vec3 positionOS = TransformWorldToObject(positionWS);
if (any(lessThan(0.5 - abs(positionOS), vec3(0.0)))) discard;

ComputeWorldSpacePosition(core 库 Common.hlsl)做的是透视除法的逆运算:屏幕 UV 和设备深度组成 NDC 坐标,乘以 UNITY_MATRIX_I_VP(视图投影矩阵的逆),再除以 w。需要注意的平台差异只有一处:D3D、Metal、Vulkan 的 NDC 深度范围是 [0, 1](Unity 在这些平台上还使用 Reversed-Z),OpenGL 是 [−1, 1],所以在 UNITY_REVERSED_Z 未定义时要先把采样到的 0..1 深度映射到 −1..1——这也是 URP 文档中重建世界坐标示例的写法。屏幕 UV 用 GetNormalizedScreenSpaceUV(positionCS) 从 SV_POSITION 换算,它会处理动态分辨率缩放。

得到世界坐标后,TransformWorldToObject 用当前物体(就是盒子)的逆矩阵变到本地空间,clip(0.5 − abs(positionOS)) 一行同时检查三个轴:任何一个分量的绝对值超过 0.5,结果为负,像素被丢弃。

Assets/Cookbook/Ch22/Editor/Ch22Shots.cs(节选:ssdObject)
/// 屏幕空间贴花就是一个挂着贴花材质的单位立方体:位置/旋转/缩放 = 投影盒
static GameObject SSD(Shot s, Material m, Vector3 pos, Vector3 euler, Vector3 size)
{
    var go = s.Cube(m, pos);
    go.name = "Screen Space Decal";
    go.transform.rotation = Quaternion.Euler(euler);
    go.transform.localScale = size;
    var r = go.GetComponent<MeshRenderer>();
    r.shadowCastingMode = ShadowCastingMode.Off;   // 投影盒本身不投影、不接收
    r.receiveShadows = false;
    return go;
}

在 C# 一侧,屏幕空间贴花就是一个普通的立方体:位置、旋转、缩放就是投影盒。它关闭了投射阴影和接收阴影;Shader 的 Tags 里还加了 "DisableBatching"="True"——静态/动态合批会把多个立方体合成一个网格、改写顶点坐标,物体矩阵就不再代表盒子了。GPU Instancing 则不受影响,因为每个实例仍有自己的矩阵。

图 22-6屏幕空间贴花的三个阶段(黄线为投影盒)。左:盒子背面通过深度测试的像素(橙色)——注意它包括盒内的箱子、球和地面,盒子后方的天空没有片元;中:重建出的盒子本地坐标 (x, y, z) + 0.5 作为颜色,盒外已被裁掉;右:用本地 xy 作 UV 贴上警示条纹
坑:贴花盒的深度方向不要正好卡在表面上代码里还有一个“盒子两端淡出”(下一案例的 depthFade),如果地面恰好位于盒子的 ±Z 端面上,贴花会完全透明——本章拍图时就踩到过:盒子中心放在 0.6 m、深度 1.2 m,地面正好在端面上,地面上的贴花全部消失。让目标表面落在盒子深度的中间部分。