案例 1.3在裁剪空间里直接画:原始深度 vs 线性深度进阶
目标:读取 URP 的相机深度纹理 _CameraDepthTexture,分别显示原始深度、Linear01Depth、LinearEyeDepth,亲眼看看深度的非线性。顺便练习一个重要技巧:顶点着色器不乘任何矩阵,直接给出裁剪空间坐标,画一个盖满屏幕的四边形。
原理:Unity 的 Quad 网格顶点在 [−0.5, 0.5],乘 2 就是 [−1, 1];令 w = 1,透视除法后 NDC 正好覆盖整个屏幕。z 放在近平面 UNITY_NEAR_CLIP_VALUE 上保证不被裁掉(并关闭深度测试)。因为绕开了投影矩阵,就要自己处理渲染到纹理时的 y 翻转:乘 _ProjectionParams.x。片元里用 GetNormalizedScreenSpaceUV 把像素坐标变成屏幕 UV,再 SampleSceneDepth。
Varyings vert(Attributes input) { Varyings output; // 直接给出裁剪空间坐标:w = 1,所以透视除法后 NDC = (x, y)。 // z 放在近裁剪面 UNITY_NEAR_CLIP_VALUE(D3D/Metal/Vulkan 是 1,OpenGL 是 -1),保证不被裁掉 output.positionCS = float4(input.positionOS.xy * 2.0, UNITY_NEAR_CLIP_VALUE, 1.0); // 渲染到中间 RT 时,Unity 的投影矩阵会上下翻转(_ProjectionParams.x = -1)。 // 我们绕过了投影矩阵,就要手动跟它保持一致,否则画面会倒过来 output.positionCS.y *= _ProjectionParams.x; return output; }
#ifdef VERTEX void main() { // 直接给出裁剪空间坐标:w = 1,所以透视除法后 NDC = (x, y)。 // z 放在近裁剪面 UNITY_NEAR_CLIP_VALUE(D3D/Metal/Vulkan 是 1,OpenGL/WebGL 是 -1),保证不被裁掉 gl_Position = vec4(positionOS.xy * 2.0, UNITY_NEAR_CLIP_VALUE, 1.0); // 渲染到中间 RT 时,Unity 的投影矩阵会上下翻转(_ProjectionParams.x = -1)。 // 我们绕过了投影矩阵,就要手动跟它保持一致,否则画面会倒过来(WebGL 里恒为 1) gl_Position.y *= _ProjectionParams.x; } #endif
half4 frag(Varyings input) : SV_Target { // 片元里的 SV_POSITION.xy 是像素坐标,除以屏幕尺寸得到 [0,1] 的屏幕 UV(内部处理了平台翻转) float2 uv = GetNormalizedScreenSpaceUV(input.positionCS); float raw = SampleSceneDepth(uv); int mode = (int)_Mode; // 线性色彩空间的工程里,颜色写进 sRGB 目标时会被“提亮”(0.07 显示成约 0.3)。 // 调试数值时先做一次 SRGBToLinear 抵消它,屏幕上的灰度才等于数值本身。 if (mode == 0) return half4(SRGBToLinear(raw.xxx), 1); // 原样输出,不做任何“美化” if (mode == 1) { float d01 = Linear01Depth(raw, _ZBufferParams); // 相机处 0(实际从 near/far 开始),远裁剪面处 1 return half4(SRGBToLinear(d01.xxx), 1); } float eye = LinearEyeDepth(raw, _ZBufferParams); // 单位:米,沿相机前方的距离 float band = frac(eye / _BandWidth); float3 col = lerp(float3(0.10, 0.35, 0.55), float3(0.95, 0.55, 0.25), saturate(eye / _EyeMax)); col *= 0.55 + 0.45 * step(0.5, band); // 交替明暗:每条色带一样宽 → 说明 eye 是线性的 return half4(col, 1); }
#ifdef FRAGMENT void main() { // 片元里的 gl_FragCoord.xy 是像素坐标,除以屏幕尺寸得到 [0,1] 的屏幕 UV vec2 uv = GetNormalizedScreenSpaceUV(gl_FragCoord); float raw = SampleSceneDepth(uv); int mode = int(_Mode); // 线性色彩空间的工程里,颜色写进 sRGB 目标时会被“提亮”(0.07 显示成约 0.3)。 // 调试数值时先做一次 SRGBToLinear 抵消它,屏幕上的灰度才等于数值本身。 if (mode == 0) { fragColor = vec4(SRGBToLinear(vec3(raw)), 1); // 原样输出,不做任何“美化” return; } if (mode == 1) { float d01 = Linear01Depth(raw, _ZBufferParams); // 相机处 0(实际从 near/far 开始),远裁剪面处 1 fragColor = vec4(SRGBToLinear(vec3(d01)), 1); return; } float eye = LinearEyeDepth(raw, _ZBufferParams); // 单位:米,沿相机前方的距离 float band = fract(eye / _BandWidth); vec3 col = mix(vec3(0.10, 0.35, 0.55), vec3(0.95, 0.55, 0.25), saturate(eye / _EyeMax)); col *= 0.55 + 0.45 * step(0.5, band); // 交替明暗:每条色带一样宽 → 说明 eye 是线性的 fragColor = vec4(col, 1); } #endif
这个 Shader 放在透明队列(Transparent+100),保证在所有不透明物体之后绘制。这里有一个 URP 14 的细节:Universal Renderer 的 Depth Texture Mode 新建工程默认是 After Transparents(UniversalRendererData.m_CopyDepthMode 的默认值),也就是深度在透明物体之后才拷贝到 _CameraDepthTexture——透明物体读到的不是当前帧的深度。配图脚本里把它改成了 CopyDepthMode.AfterOpaques,你在编辑器里要在 Renderer 资产的 Inspector 中把 Depth Texture Mode 设为 After Opaques。第 2 章会细讲这个开关。

左图几乎全黑——这不是 bug,而是 Reversed-Z 原始深度的真实面貌:只有离相机一两米内的地面还有一点灰度,其余场景都挤在接近 0 的数值里(对照图 1-15 的曲线)。中图的 Linear01Depth 才是直觉中的“深度图”:由近到远均匀变亮,天空处为 1(远平面)。右图的色带宽度在画面上近宽远窄,但在三维空间里每条都正好 1 米——这证明 LinearEyeDepth 的单位就是米,可以直接拿去和物体的观察深度相减,算“水有多深”“粒子离墙多近”(第 19、21 章)。
SRGBToLinear,抵消线性工程写入 sRGB 帧缓冲时的编码。不做这一步,左图会显得灰蒙蒙的,从而误以为深度值比实际大得多。调试“数值”时这是个好习惯。常见坑:
- 深度纹理一片纯黑或纯白:URP Asset 里没勾 Depth Texture,URP 会把
_CameraDepthTexture设成一张代表“远平面”的纹理(Reversed-Z 平台为黑色)。 - 画面上下颠倒:顶点里忘了乘
_ProjectionParams.x,或者自己用positionCS.xy / _ScreenParams.xy算 UV 而没处理翻转。 - 全屏四边形不显示:它的 MeshRenderer 仍然要经过 CPU 视锥剔除,包围盒必须在相机视野内(配图脚本把它放在相机正前方 2 米处)。第 25 章用 Renderer Feature 画全屏,就不再有这个问题。
本案例出自《URP 渲染实战》第 1 章「从像素说起:GPU 渲染管线与坐标空间」。在章节中阅读上下文 →