TA萌新的老大难:MVP矩阵与深度图!一文看懂
小猴哥技术美术
ChatGPT Image Jul 4, 2026, 03_50_36 PM.png

你可能已经会在 Shader 里采样 _CameraDepthTexture 做扫描线、水面泡沫,但是"深度图里存的到底是什么值?为什么它是非线性的?为什么 DirectX 和 OpenGL 下长得不一样?"

目录

  1. 为什么 TA 必须搞懂深度

  2. MVP:一个顶点的旅程

  3. 深度值的诞生:透视除法与非线性

  4. 平台差异与 Reversed-Z

  5. 在 Unity 中获取和采样深度图

  6. 深度线性化:Linear01Depth 与 LinearEyeDepth

  7. 实战应用

  8. 常见坑

一、为什么 TA 必须搞懂深度

深度图(Depth Texture)是屏幕后处理和场景特效的"万能钥匙"。下面这些你在项目里天天见的效果,全都建立在深度图之上:

  • 扫描线 / 全息效果:按深度扫过整个场景

  • 水面边缘泡沫、岸边渐变:比较水面深度和水下物体深度

  • 软粒子(Soft Particles):粒子与不透明物体相交处柔和过渡

  • 能量护盾交接光:护盾与场景相交的地方发光

  • 景深(DoF)、SSAO、屏幕空间雾效:都要靠深度重建距离甚至世界坐标

  • Hi-Z 剔除、屏幕空间反射(SSR):GPU Driven 管线的基础设施

而深度值本身,是顶点经过 MVP 变换 + 透视除法 之后的产物。不理解 MVP,深度图的一切"怪现象"(非线性、平台翻转、精度闪烁)都无法解释。所以我们从头说起。

二、MVP:一个顶点的旅程

一个模型顶点要显示到屏幕上,要经历四个坐标空间的旅行:

image.png

M、V、P 三个矩阵,就是这段旅程的三张"车票"。

2.1 齐次坐标:为什么是 4 维?

先解决一个新手最常见的疑问:明明是三维空间,为什么顶点和矩阵都是 4 维的?

看平移就懂了。缩放和旋转都能写成 3×3 矩阵乘法:

image.png

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

image.png

这就是齐次坐标:用 4 维统一表示"缩放、旋转、平移"三种变换,让整条管线都能用统一的矩阵乘法来处理。后面你会看到,这个 w 分量在投影阶段还有更大的用处。

一个容易被忽略的观点:矩阵只是变换结果的"打包形式"。先推导出变换后的坐标表达式,再把它拆解成矩阵×向量——管线里所有矩阵(包括投影矩阵)都是这么来的,矩阵不是目的,只是统一表示的手段。

2.2 Model 矩阵:从模型空间到世界空间

Model 矩阵负责把模型自身坐标系下的顶点摆放到世界中,由缩放(S)、旋转(R)、平移(T)复合而成。

顺序非常重要:必须是 先缩放 → 再旋转 → 最后平移

image.png

为什么?假如先平移再缩放,平移量也会被一起缩放:(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 方法):

  1. 视线的反方向作为 D 轴:D = normalize(eye - target)。注意这里是反方向——右手系约定相机看向 -z,所以矩阵里这个轴指向相机后方,别按"朝向"代入 forward,不然后面的叉乘对不上

  2. 假设一个世界上方向 Up = (0,1,0)

  3. 叉乘得到右轴:R = cross(Up, D)

  4. 再叉乘得到真正的上轴:U = cross(D, R)

View 矩阵 = 旋转矩阵(把世界轴对齐到相机轴)× 平移矩阵(把相机移回原点):

image.png

⚠️ 注意第四列是"轴向量与 -P 的点积",不是简单的 -Px、-Py、-Pz。这是因为旋转矩阵乘平移矩阵时,平移分量会被旋转部分作用。很多手写推导(包括一些流传很广的博客)在这里写错,参考文章一的评论区就集中讨论了这个错误。

另外注意 look-at 方法的死锁问题:当相机垂直向上/向下看时,视线方向与假设的 Up 平行,叉乘结果为零向量,矩阵失效。工程上要么限制俯仰角(比如 ±89°),要么改用四元数构造。

还有一个常见疑问:"为什么 M 矩阵是 缩放→旋转→平移,V 矩阵却是 平移→旋转?"因为 V 是把相机归位回原点的逆过程:相机摆到世界里是 SRT,那么逆回来自然就是先逆平移、再逆旋转(相机没有缩放)。

2.4 Projection 矩阵:压进标准立方体

投影矩阵的目标:把视锥体(Frustum)内的顶点映射到一个标准立方体里,即 NDC(Normalized Device Coordinates,归一化设备坐标),xy 范围 [-1,1],z 范围因平台而异(下一节细说)。

image.png

分两种:

正交投影(Orthographic):视野是个长方体,xyz 三个方向都是均匀线性缩放。没有近大远小,常用于 2D 游戏、UI、阴影贴图(方向光)。

透视投影(Perspective):视野是个四棱台(近平面小、远平面大),模拟人眼"近大远小"。这是重头戏。

透视投影的核心思想是相似三角形:一个观察空间中的点 (xv,yv,zv)(xv​,yv​,zv​) 投到近平面(距离 n)上时:


image.png

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

image.png

问题来了:这个式子里 x、y 要除以 z,而"除以某个分量"是矩阵乘法表达不出来的——矩阵只能做线性组合。怎么办?

管线的解决方案非常优雅:把除法拆出来,延迟执行

  1. 投影矩阵负责把 −zv−zv​ 塞进结果的 w 分量 里;

  2. 管线在裁剪之后统一执行 透视除法(Perspective Division):所有分量除以 w。

这就是齐次坐标 w 分量的第二个使命。OpenGL 风格的透视投影矩阵(对称视锥)长这样:

image.png

其中 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,即到相机的直线深度),投影 + 透视除法后:

image.png

代入验证:zv=nzv​=n 时 zNDC=0zNDC​=0(近平面),zv=fzv​=f 时 zNDC=1zNDC​=1(远平面)

3.2 关键性质:深度与 1/z 成正比

盯着上面的式子看:zNDCzNDC​ 是关于 1zvzv​1​ 的一次函数,不是关于 zvzv​ 的一次函数。这就是"深度图是非线性的"的数学本质。

以 n=0.1、f=10 为例画出曲线:

image.png

  • zv​ 从 0.1 走到 1(占视距的 9%),zNDCzNDC​ 已经从 0 飙到约 0.91

  • zvzv​ 从 1 走到 10(占视距的 91%),zNDCzNDC​ 只从 0.91 挪到 1.0

也就是说:深度缓冲的绝大部分数值区间,都分配给了离相机很近的那一小段距离。

为什么故意设计成这样?两个原因:

  1. 硬件插值的需要:屏幕空间中做线性插值时,只有 1zz1​ 相关的量才能被正确地线性插值(透视校正插值的原理),NDC 深度天然满足这一点,光栅化阶段可以直接对它做线性插值。

  2. 近处需要更高精度:玩家眼前的物体互相穿插最容易被看出来,远处的物体谁前谁后差几厘米根本看不出来。

但副作用也很明显:远处精度太低,容易 Z-Fighting(两个面深度值接近,精度不够区分谁前谁后,画面闪烁)。于是有了下一节的 Reversed-Z。

四、平台差异与 Reversed-Z

4.1 两大阵营的 NDC 差异

image.png

经典现象:同一个场景,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 的非线性和浮点的准对数分布互相抵消,近处精度依然极高,远处精度大幅改善。

image.png

Reversed-Z 配合浮点深度缓冲,能以零性能、零内存成本大幅提升深度精度,近乎消灭 Z-Fighting。这也是它成为现代引擎(Unity、Unreal、Godot 4.3+)标配的原因。

4.3 Z-Fighting 咋整?

即便有 Reversed-Z,重叠面片仍可能闪烁。按性价比排序:

  1. 别把两个面贴在同一位置(美术侧根治:地面上的贴花模型抬高一点,或用 decal 系统)

  2. 把近裁剪面 near 调大——深度精度对 near 极其敏感,near 从 0.01 调到 0.3,远处精度提升几十倍;far 的影响反而很小

  3. 缩短 near 与 far 的整体跨度

  4. 使用更高位深的深度缓冲(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 流程里产出,算"顺便",生成深度图无额外开销。

image.png

几个重要规则:

  • 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 等反转平台
#endif

5.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,无论哪个平台,化简后都得到同一个干净的结果:

image.png

就是观察空间 z 除以远平面距离。同理 LinearEyeDepth 直接反解出 zvzv​ 本身。

两个注意点:

  • 线性化之后各平台结果统一:近→远 = 0→1,不用再自己处理 UNITY_REVERSED_Z

  • Linear01Depth 为 0 时代表相机原点而不是近平面(公式里 near 已经被约掉了)。

6.3 三种"深度"术语含义

image.png

七、用法

以下示例以 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 里采样深度图,拿到的是水下不透明物体的深度。两者一减就是"水深":

image.png
// 顶点着色器:把屏幕坐标作为 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逆矩阵的方式反推世界坐标,也可以用相机的射线的方式去做,有兴趣的小伙伴可以研究一下

八、常见坑

  1. 深度图全黑 → far 太大导致数值太小,不是没生成。改小 far 或放大显示。

  2. 某个物体在深度图里消失 → Built-in 下检查 Shader 是否有 ShadowCaster Pass(或 Fallback );检查 是否开启深度写入 Zwrit On。

  3. 半透明物体采样不到自己 / 互相采样不到 → 设计如此,深度图只含不透明物体。需要半透明深度请自己用 RT + 替换材质额外画一张。

  4. 效果在编辑器正常、真机上深度反了 → 忘了处理 UNITY_REVERSED_Z,或干脆统一改用 Linear01Depth / LinearEyeDepth(推荐,函数内部已抹平平台差异)。

  5. 扫描线/雾效近处慢远处快 → 用了原始非线性深度,改用 LinearEyeDepth

  6. 远处物体闪烁(Z-Fighting) → 调大 near(收益最大)、避免共面、提高深度缓冲位深。

  7. Built-in 下 C# 抓 _CameraDepthTexture 抓到的是 Scene 视图的深度 → 获取时机问题,在 OnPostRenderGraphics.Blit(Shader.GetGlobalTexture("_CameraDepthTexture"), rt) 才能拿到当前 Game 相机这一帧的深度。

  8. URP 里采样报错找不到 _CameraDepthTexture → 没勾 URP Asset 的 Depth Texture,或该 Pass 执行时深度图还没生成(注意 RenderPassEvent 顺序)。

  9. 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 的不均匀)→ _CameraDepthTextureLinearEyeDepth 还原成真实距离 → 扫描线、水面泡沫、软粒子、世界坐标重建。