
你可能已经会在 Shader 里采样 _CameraDepthTexture 做扫描线、水面泡沫,但是"深度图里存的到底是什么值?为什么它是非线性的?为什么 DirectX 和 OpenGL 下长得不一样?"
目录
为什么 TA 必须搞懂深度
MVP:一个顶点的旅程
深度值的诞生:透视除法与非线性
平台差异与 Reversed-Z
在 Unity 中获取和采样深度图
深度线性化:Linear01Depth 与 LinearEyeDepth
实战应用
常见坑
一、为什么 TA 必须搞懂深度
深度图(Depth Texture)是屏幕后处理和场景特效的"万能钥匙"。下面这些你在项目里天天见的效果,全都建立在深度图之上:
扫描线 / 全息效果:按深度扫过整个场景
水面边缘泡沫、岸边渐变:比较水面深度和水下物体深度
软粒子(Soft Particles):粒子与不透明物体相交处柔和过渡
能量护盾交接光:护盾与场景相交的地方发光
景深(DoF)、SSAO、屏幕空间雾效:都要靠深度重建距离甚至世界坐标
Hi-Z 剔除、屏幕空间反射(SSR):GPU Driven 管线的基础设施
而深度值本身,是顶点经过 MVP 变换 + 透视除法 之后的产物。不理解 MVP,深度图的一切"怪现象"(非线性、平台翻转、精度闪烁)都无法解释。所以我们从头说起。
二、MVP:一个顶点的旅程
一个模型顶点要显示到屏幕上,要经历四个坐标空间的旅行:

M、V、P 三个矩阵,就是这段旅程的三张"车票"。
2.1 齐次坐标:为什么是 4 维?
先解决一个新手最常见的疑问:明明是三维空间,为什么顶点和矩阵都是 4 维的?
看平移就懂了。缩放和旋转都能写成 3×3 矩阵乘法:

但平移是"加法":(x+Tx, y+Ty, z+Tz),3×3 矩阵怎么乘都乘不出一个常数加项。解决办法是给顶点补一个 w=1 的第四维,用 4×4 矩阵的第四列存平移量:

这就是齐次坐标:用 4 维统一表示"缩放、旋转、平移"三种变换,让整条管线都能用统一的矩阵乘法来处理。后面你会看到,这个 w 分量在投影阶段还有更大的用处。
一个容易被忽略的观点:矩阵只是变换结果的"打包形式"。先推导出变换后的坐标表达式,再把它拆解成矩阵×向量——管线里所有矩阵(包括投影矩阵)都是这么来的,矩阵不是目的,只是统一表示的手段。
2.2 Model 矩阵:从模型空间到世界空间
Model 矩阵负责把模型自身坐标系下的顶点摆放到世界中,由缩放(S)、旋转(R)、平移(T)复合而成。
顺序非常重要:必须是 先缩放 → 再旋转 → 最后平移:

为什么?假如先平移再缩放,平移量也会被一起缩放:(Sx(x+Tx), Sy(y+Ty), Sz(z+Tz))——你想让物体移动 5 米,结果因为缩放了 2 倍它跑了 10 米。这就是美术同学在 DCC 里 freeze transform 前后表现不一致的底层原因之一。
2.3 View 矩阵:把世界搬到相机眼前
View 矩阵的目标:把所有顶点从世界坐标系变换到以相机为原点、以相机朝向为轴向的观察空间。它做的事情本质上就是 Model 变换的逆过程——与其说"移动相机去看世界",不如说"把整个世界反向移动到相机面前"。
构造 View 矩阵需要相机位置 P 和三个正交轴(右 R、上 U、后 D),三个轴一般用叉乘求出(look-at 方法):
取视线的反方向作为 D 轴:
D = normalize(eye - target)。注意这里是反方向——右手系约定相机看向 -z,所以矩阵里这个轴指向相机后方,别按"朝向"代入 forward,不然后面的叉乘对不上假设一个世界上方向
Up = (0,1,0)叉乘得到右轴:
R = cross(Up, D)再叉乘得到真正的上轴:
U = cross(D, R)
View 矩阵 = 旋转矩阵(把世界轴对齐到相机轴)× 平移矩阵(把相机移回原点):

⚠️ 注意第四列是"轴向量与 -P 的点积",不是简单的 -Px、-Py、-Pz。这是因为旋转矩阵乘平移矩阵时,平移分量会被旋转部分作用。很多手写推导(包括一些流传很广的博客)在这里写错,参考文章一的评论区就集中讨论了这个错误。
另外注意 look-at 方法的死锁问题:当相机垂直向上/向下看时,视线方向与假设的 Up 平行,叉乘结果为零向量,矩阵失效。工程上要么限制俯仰角(比如 ±89°),要么改用四元数构造。
还有一个常见疑问:"为什么 M 矩阵是 缩放→旋转→平移,V 矩阵却是 平移→旋转?"因为 V 是把相机归位回原点的逆过程:相机摆到世界里是 SRT,那么逆回来自然就是先逆平移、再逆旋转(相机没有缩放)。
2.4 Projection 矩阵:压进标准立方体
投影矩阵的目标:把视锥体(Frustum)内的顶点映射到一个标准立方体里,即 NDC(Normalized Device Coordinates,归一化设备坐标),xy 范围 [-1,1],z 范围因平台而异(下一节细说)。

分两种:
正交投影(Orthographic):视野是个长方体,xyz 三个方向都是均匀线性缩放。没有近大远小,常用于 2D 游戏、UI、阴影贴图(方向光)。
透视投影(Perspective):视野是个四棱台(近平面小、远平面大),模拟人眼"近大远小"。这是重头戏。
透视投影的核心思想是相似三角形:一个观察空间中的点 (xv,yv,zv)(xv,yv,zv) 投到近平面(距离 n)上时:

(OpenGL 风格右手系,相机看向 -z 方向,故取 −zv−zv。)

问题来了:这个式子里 x、y 要除以 z,而"除以某个分量"是矩阵乘法表达不出来的——矩阵只能做线性组合。怎么办?
管线的解决方案非常优雅:把除法拆出来,延迟执行。
投影矩阵负责把 −zv−zv 塞进结果的 w 分量 里;
管线在裁剪之后统一执行 透视除法(Perspective Division):所有分量除以 w。
这就是齐次坐标 w 分量的第二个使命。OpenGL 风格的透视投影矩阵(对称视锥)长这样:

其中 n、f 是近/远裁剪面距离,r、t 是近平面的半宽/半高(由 FOV 和宽高比决定)。注意第四行 (0,0,-1,0)——它就是"把 -z 写进 w"的那一行。
乘完 P 得到的是裁剪空间(Clip Space)坐标,做完透视除法才是 NDC。 这两个空间新手最容易混淆:裁剪空间是齐次的 4 维坐标(w 一般不是 1),NDC 是除完 w 之后的 3 维标准立方体。
至此 MVP 全流程:
// 等价于 Unity 里的 UnityObjectToClipPos / TransformObjectToHClip
float4 positionCS = mul(P, mul(V, mul(M, float4(positionOS, 1.0))));
// 光栅化前硬件自动执行透视除法:
float3 ndc = positionCS.xyz / positionCS.w;而深度图里存的,就是这个 ndc.z(经过视口变换映射到 [0,1])——我们的主角终于登场了。
三、深度值的诞生:透视除法与非线性
3.1 推导深度公式
以 DirectX 风格(左手系观察空间,z 范围映射到 [0,1])为例。顶点经过 MV 变换后 z 值为 zvzv(也叫 eye Z / view Z,即到相机的直线深度),投影 + 透视除法后:

代入验证:zv=nzv=n 时 zNDC=0zNDC=0(近平面),zv=fzv=f 时 zNDC=1zNDC=1(远平面)
3.2 关键性质:深度与 1/z 成正比
盯着上面的式子看:zNDCzNDC 是关于 1zvzv1 的一次函数,不是关于 zvzv 的一次函数。这就是"深度图是非线性的"的数学本质。
以 n=0.1、f=10 为例画出曲线:

zv 从 0.1 走到 1(占视距的 9%),zNDCzNDC 已经从 0 飙到约 0.91
zvzv 从 1 走到 10(占视距的 91%),zNDCzNDC 只从 0.91 挪到 1.0
也就是说:深度缓冲的绝大部分数值区间,都分配给了离相机很近的那一小段距离。
为什么故意设计成这样?两个原因:
硬件插值的需要:屏幕空间中做线性插值时,只有 1zz1 相关的量才能被正确地线性插值(透视校正插值的原理),NDC 深度天然满足这一点,光栅化阶段可以直接对它做线性插值。
近处需要更高精度:玩家眼前的物体互相穿插最容易被看出来,远处的物体谁前谁后差几厘米根本看不出来。
但副作用也很明显:远处精度太低,容易 Z-Fighting(两个面深度值接近,精度不够区分谁前谁后,画面闪烁)。于是有了下一节的 Reversed-Z。
四、平台差异与 Reversed-Z
4.1 两大阵营的 NDC 差异

经典现象:同一个场景,Windows(D3D11)下深度图"近红远黑",Android(GLES3)下却"近黑远红"——因为 Unity 在 D3D 类平台上做了 Reversed-Z 反转,而 OpenGL 类平台没做。
Unity 从 5.5 版本开始在 D3D11、D3D12、PS4、Xbox One、Metal 等平台反转深度方向:近平面 depth=1.0,远平面 depth=0.0。在 shader 中用宏 UNITY_REVERSED_Z 判断当前平台是否反转。
4.2 Reversed-Z:免费的精度魔法
把深度反过来存(近=1,远=0)为什么能救精度?单看曲线似乎没用——不还是近处占了大部分数值区间吗?
关键在于浮点数本身的精度分布:IEEE 浮点数在越接近 0 的地方,可表示的数值越密集(指数位每降一档,精度翻倍),呈准对数分布。
普通 Z:1/z1/z 曲线把大部分编码区间给了近处,浮点又把高精度区给了 0 附近(也是近处)→ 两者叠加,近处精度过剩到浪费,远处精度双重匮乏。
Reversed-Z:远处的深度值被映射到 0 附近,正好落在浮点数精度最高的区域 → 1/z1/z 的非线性和浮点的准对数分布互相抵消,近处精度依然极高,远处精度大幅改善。

Reversed-Z 配合浮点深度缓冲,能以零性能、零内存成本大幅提升深度精度,近乎消灭 Z-Fighting。这也是它成为现代引擎(Unity、Unreal、Godot 4.3+)标配的原因。
4.3 Z-Fighting 咋整?
即便有 Reversed-Z,重叠面片仍可能闪烁。按性价比排序:
别把两个面贴在同一位置(美术侧根治:地面上的贴花模型抬高一点,或用 decal 系统)
把近裁剪面 near 调大——深度精度对 near 极其敏感,near 从 0.01 调到 0.3,远处精度提升几十倍;far 的影响反而很小
缩短 near 与 far 的整体跨度
使用更高位深的深度缓冲(24 bit → 32 bit float)
五、在 Unity 中获取和采样深度图
5.1 深度图从哪来?
"深度图是物体正常渲染时深度测试顺便存下来的"——这句话对不对,取决于管线:
Built-in 前向渲染:不是顺便的。开启
DepthTextureMode.Depth后,Unity 会用一个专门的 Pass(UpdateDepthTexture)先把场景不透明物体画一遍,只输出深度,生成_CameraDepthTexture;随后才是正常的不透明物体渲染。用 Frame Debugger(Window → Analysis → Frame Debugger)点开 UpdateDepthTexture 就能看到这张图和每个 mesh 的写入顺序。URP:两种来源都有可能。要么是单独的 Depth Prepass(提前画一遍),要么是不透明物体渲染完后的 Copy Depth——后者就是字面意义上的"顺便存下来的"深度缓冲,只是多拷贝了一份(深度缓冲被绑定做深度测试时不能同时被采样,必须拷出来才能用)。具体走哪条路取决于管线设置和平台。
延迟渲染(Deferred):深度本来就在 G-Buffer 流程里产出,算"顺便",生成深度图无额外开销。

几个重要规则:
Built-in:深度图由物体 Shader 的 ShadowCaster Pass 渲染。Shader 里没有 ShadowCaster、Fallback 也不含的话,这个物体不会出现在深度图里(Surface Shader 可加
addshadow指令自动生成)。URP:Depth Prepass 靠的是物体 Shader 的 DepthOnly Pass(不是 ShadowCaster)。自定义 Shader 缺这个 Pass 的话,走 Prepass 模式时物体同样会从深度图里消失。
只有不透明物体会写入深度图,半透明物体一律不写(Shader默认都是ZWriteOn,半透明物体得手动关闭深度写入 ZWriteOFF)
5.2 Built-in 管线
// C#:让相机生成深度图
Camera.main.depthTextureMode |= DepthTextureMode.Depth;
// Shader:采样全局变量 _CameraDepthTexture
sampler2D _CameraDepthTexture;
float4 frag(v2f i) : SV_Target
{
// UNITY_SAMPLE_DEPTH 本质就是取 .r 通道
float rawDepth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv));
return float4(rawDepth.xxx, 1.0);
}
如果要在各平台上统一成"近 0 远 1",处理一下反转宏:
float depth = tex2D(_CameraDepthTexture, i.uv).r;
#if defined(UNITY_REVERSED_Z)
depth = 1.0 - depth; // D3D / Metal 等反转平台
#endif5.3 URP
URP 下开启方式变成勾选项:URP Asset → Depth Texture(或在 Camera 上单独覆盖)。采样有现成的头文件:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
half4 frag(Varyings input) : SV_Target
{
// 屏幕 UV:裁剪空间坐标除以 w 再映射,或直接用 GetNormalizedScreenSpaceUV
float2 screenUV = input.positionCS.xy / _ScaledScreenParams.xy;
float rawDepth = SampleSceneDepth(screenUV); // 原始非线性深度
float linear01 = Linear01Depth(rawDepth, _ZBufferParams); // 0~1 线性
float eyeDepth = LinearEyeDepth(rawDepth, _ZBufferParams);// 观察空间距离(米)
return half4(linear01.xxx, 1);
}Shader Graph 简单:直接用 Scene Depth 节点,模式可选 Raw / Linear01 / Eye。
无论深度图来自 Depth Prepass 还是 Copy Depth,里面都只有不透明物体——所以半透明物体永远采样不到自己的深度,但是可以直接计算出自己的深度然后再和深度图进行一些计算,这是特性不是 bug(软粒子正是利用了这一点)。
六、深度线性化:Linear01Depth 与 LinearEyeDepth
6.1 为什么要线性化
直接拿原始深度做效果会翻车。以扫描线为例:让扫描半径均匀地随原始深度增长,你会看到扫描线在眼前慢得像蜗牛(深度 1→0.05 可能只对应眼前 1 米),然后"啪"地瞬移到天边(0.05→0 覆盖了剩余全部距离)。因为原始深度和实际距离是 1/z1/z 关系,必须先转回线性。
6.2 两个函数的推导
Unity 提供两个线性化函数(Built-in 在 UnityCG.cginc,URP 在 Common.hlsl,URP 版本多一个 _ZBufferParams 参数):
// 原始深度 → [0,1] 线性深度(0=相机原点,1=远平面)
inline float Linear01Depth(float z)
{
return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
// 原始深度 → 观察空间 z 值(单位:米,即 eye depth)
inline float LinearEyeDepth(float z)
{
return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}_ZBufferParams 是 Unity 预先按平台算好的系数:
// 普通平台(OpenGL 类) // Reversed-Z 平台(D3D 类)
x = 1 - far/near x = -1 + far/near
y = far/near y = 1
z = x/far z = x/far
w = y/far w = 1/far把第三节的深度公式代入 Linear01Depth,无论哪个平台,化简后都得到同一个干净的结果:

就是观察空间 z 除以远平面距离。同理 LinearEyeDepth 直接反解出 zvzv 本身。
两个注意点:
线性化之后各平台结果统一:近→远 = 0→1,不用再自己处理
UNITY_REVERSED_Z。Linear01Depth为 0 时代表相机原点而不是近平面(公式里 near 已经被约掉了)。
6.3 三种"深度"术语含义

七、用法
以下示例以 URP 为主(Built-in 只需替换采样宏),核心逻辑完全一致。
7.1 深度可视化(调试必备)
float raw = SampleSceneDepth(screenUV);
float linear01 = Linear01Depth(raw, _ZBufferParams);
// 远平面很远时整体会偏黑,乘个系数方便观察
return half4(saturate(linear01 * _DebugScale).xxx, 1);顺带解答那个经典疑问:"为什么我的深度图看起来全黑?"——默认相机 near=0.3、far=1000,一个 10 米外的物体线性深度只有 0.01,非线性深度在 Reversed-Z 下更是接近 0。不是没写进去,是值太小看不见。调试时把 far 改小(比如 100 以内),或乘个放大系数即可。
7.2 全场景扫描线
后处理 Shader,核心思路:把每个像素的深度还原为距离,与一个随时间推进的扫描半径比较:
float raw = SampleSceneDepth(uv);
float eyeDepth = LinearEyeDepth(raw, _ZBufferParams);
float scanDist = frac(_Time.y * _ScanSpeed) * _MaxDistance;
// 落在扫描带内的像素混合扫描色
float band = 1.0 - saturate(abs(eyeDepth - scanDist) / _ScanWidth);
color.rgb = lerp(color.rgb, _ScanColor.rgb, band * _ScanColor.a);这里必须用 LinearEyeDepth——用原始深度就会出现 6.1 说的"眼前蜗牛、远处瞬移"。
7.3 水面边缘泡沫 / 深度渐变
技术美术专业课中风格水边缘的白边泡沫就是用这种方式实现的
水面是半透明物体(不写深度图),所以在水面 shader 里采样深度图,拿到的是水下不透明物体的深度。两者一减就是"水深":

// 顶点着色器:把屏幕坐标作为 varying 传下来(它的 .w 分量 = 裁剪空间 w = eye depth)
output.screenPos = ComputeScreenPos(vertexInput.positionCS);
// 片元着色器:
float2 screenUV = input.screenPos.xy / input.screenPos.w;
// 场景(水底)的观察空间深度
float sceneEyeDepth = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
// 水面自身的观察空间深度
float surfaceEyeDepth = input.screenPos.w;
float waterDepth = sceneEyeDepth - surfaceEyeDepth; // 视线方向上的水深
float foam = 1.0 - saturate(waterDepth / _FoamRange); // 越浅泡沫越强
half3 water = lerp(_ShallowColor, _DeepColor, saturate(waterDepth / _DepthRange));岸边、石头与水面的交界处 waterDepth ≈ 0,自然形成一圈泡沫。能量护盾的交接发光是完全相同的套路。
别在片元里直接读 input.positionCS.w(SV_POSITION 的 w)当 eye depth 用:D3D 下它是裁剪空间 w 没错,但 OpenGL 的 gl_FragCoord.w 规范定义是 1/w,两边语义不一致,真机上会翻车。老老实实从顶点着色器传 varying 下来(ComputeScreenPos 的 .w 就是裁剪空间 w),跨平台才稳——URP 内置的软粒子实现也是这么做的。
7.4 软粒子(Soft Particles)
粒子片插进地面时会切出一条生硬的直线。修法:粒子与背后场景深度差越小,越透明:
// screenPos 同 7.3,由顶点着色器的 ComputeScreenPos 传下来
float2 screenUV = input.screenPos.xy / input.screenPos.w;
float sceneEyeDepth = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
float particleEyeDepth = input.screenPos.w;
float fade = saturate((sceneEyeDepth - particleEyeDepth) / _SoftRange);
color.a *= fade;7.5 深度重建世界坐标
许多屏幕空间效果(贴花、体积雾、扫描图案贴地)需要知道"这个像素对应的世界坐标"。原理:屏幕 UV + 深度 = 完整的 NDC 坐标,再乘 VP 逆矩阵变回世界空间。URP 提供了现成函数:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
float raw = SampleSceneDepth(screenUV);
// 注意:ComputeWorldSpacePosition 内部只处理了 Y 翻转,没处理 NDC z 范围的平台差异。
// OpenGL 类平台的 NDC z 是 [-1,1],必须先手动重映射(URP 官方文档的示例同款):
#if !UNITY_REVERSED_Z
raw = lerp(UNITY_NEAR_CLIP_VALUE, 1, raw);
#endif
// UNITY_MATRIX_I_VP:VP 逆矩阵
float3 worldPos = ComputeWorldSpacePosition(screenUV, raw, UNITY_MATRIX_I_VP);两个注意点:这里用的是 Raw Depth(重建走的是标准逆变换,需要原始 NDC 深度),不要先线性化再传进去;那段 #if !UNITY_REVERSED_Z 不能省,少了它在 GL/GLES 平台上重建出来的世界坐标是错的。
除了可以用vp逆矩阵的方式反推世界坐标,也可以用相机的射线的方式去做,有兴趣的小伙伴可以研究一下
八、常见坑
深度图全黑 → far 太大导致数值太小,不是没生成。改小 far 或放大显示。
某个物体在深度图里消失 → Built-in 下检查 Shader 是否有 ShadowCaster Pass(或 Fallback );检查 是否开启深度写入 Zwrit On。
半透明物体采样不到自己 / 互相采样不到 → 设计如此,深度图只含不透明物体。需要半透明深度请自己用 RT + 替换材质额外画一张。
效果在编辑器正常、真机上深度反了 → 忘了处理
UNITY_REVERSED_Z,或干脆统一改用Linear01Depth/LinearEyeDepth(推荐,函数内部已抹平平台差异)。扫描线/雾效近处慢远处快 → 用了原始非线性深度,改用
LinearEyeDepth。远处物体闪烁(Z-Fighting) → 调大 near(收益最大)、避免共面、提高深度缓冲位深。
Built-in 下 C# 抓
_CameraDepthTexture抓到的是 Scene 视图的深度 → 获取时机问题,在OnPostRender里Graphics.Blit(Shader.GetGlobalTexture("_CameraDepthTexture"), rt)才能拿到当前 Game 相机这一帧的深度。URP 里采样报错找不到
_CameraDepthTexture→ 没勾 URP Asset 的 Depth Texture,或该 Pass 执行时深度图还没生成(注意 RenderPassEvent 顺序)。Tip:半透明物体的三要素:1、alpha blend(插值模式 or 叠加模式) 2、队列3000(保证实心物体画完了之后再画半透明物体,因为其要进行颜色融合) 3、关闭深度写入(ZWrite Off)
总结,走个面儿
顶点 → M(摆进世界,S→R→T 顺序)→ V(搬到相机面前,look-at)→ P(压进标准立方体,把 -z 藏进 w)→ 透视除法(除以 w,深度从此与 1/z 成正比、变得非线性)→ 深度缓冲(D3D 类平台被 Unity 做了 Reversed-Z,利用浮点精度分布对冲 1/z 的不均匀)→ _CameraDepthTexture → LinearEyeDepth 还原成真实距离 → 扫描线、水面泡沫、软粒子、世界坐标重建。