Shader 里的 MVP:顶点从模型空间到裁剪空间经历了什么

前面几篇主要站在客户端和 Transform 的角度讲矩阵,这一篇换一个视角:从 Shader 看顶点变换。

我自己第一次真正把这条链想明白,不是在看教材,而是在改 Shader 的时候发现“宏虽然能跑,但只要要接世界空间法线、屏幕空间效果或者深度效果,脑子里没这条空间链就一定会乱”。

很多 Unity 程序员第一次真正意识到矩阵的重要性,往往不是在 Transform,而是在 Shader 里看到一长串空间名:

  • Object Space
  • World Space
  • View Space
  • Clip Space

如果不把这条链串起来,写 Shader 很容易变成“宏能跑就行”。

一、MVP 其实就是一条连续的坐标转换链

MVP 分别是:

  • Model
  • View
  • Projection

它们描述的不是三种孤立矩阵,而是顶点要经过的三段路:

  1. 模型空间到世界空间
  2. 世界空间到观察空间
  3. 观察空间到裁剪空间

一个顶点从模型自己的局部坐标出发,最终能不能落到屏幕上,就靠这条链。

二、模型空间:顶点最初的家

模型空间就是美术资源自身的局部坐标系。

比如一个立方体资源,它的顶点可能天然围绕 (0, 0, 0) 排布。

这个阶段,顶点只知道自己在模型里的相对位置,不知道:

  • 这个物体被摆到场景哪里了
  • 它朝什么方向
  • 摄像机站在哪看它

所以模型空间的顶点,必须先经过 Model 变换。

三、Model 矩阵:把局部顶点带进世界

Model 矩阵本质上就是 localToWorldMatrix

它把模型自己的顶点,从局部坐标转换到场景世界坐标。

在 Unity 语境里,你可以把它理解成:

这个物体在场景中的 TRS 总结果。

如果没有这一步,顶点永远只能待在资源自己的小世界里。

四、View 矩阵:把世界重新换成“相机眼中的世界”

世界空间看起来很自然,但 GPU 真正做投影前,更关心的是“相机视角下”的位置。

所以接下来要用 View 矩阵,把世界空间转换成观察空间。

你可以把它理解成:

不是相机去看世界,而是把整个世界搬到相机坐标系里。

做完这一步之后,相机就像站在原点,朝固定方向看出去。

五、Projection 矩阵:把三维体积压到可裁剪的空间里

到了观察空间,顶点已经相对于相机定位好了,但还没完成“投影”。

Projection 矩阵做的事情,是把三维观察体映射到一个规范裁剪空间。

这个阶段会引入:

  • 透视缩小
  • 近平面和远平面范围
  • 屏幕最终可见区域

如果是透视相机,远处物体会变小;如果是正交相机,则不会有透视缩放。

这就是 Projection 的核心区别。

六、为什么 Shader 里经常直接写 MVP

因为对顶点来说,这三段变换是连续的。

所以完全可以把它们预先合成一个总矩阵:

$$
MVP = P \cdot V \cdot M
$$

然后一次把模型顶点送到裁剪空间。

这对 GPU 很友好,也符合渲染流水线的处理方式。

七、Unity 里最常见的对应接口和宏

在 Unity Shader 里,你通常会碰到这些:

  • unity_ObjectToWorld
  • UNITY_MATRIX_V
  • UNITY_MATRIX_P
  • UNITY_MATRIX_MVP

如果你只把它们当神秘常量,后面写世界空间法线、屏幕空间效果、深度相关逻辑时会很吃力。

但如果你知道它们分别在做哪段空间转换,就会清楚很多。

例如顶点着色器里最常见的最小路径,其实就长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct appdata
{
float4 vertex : POSITION;
};

struct v2f
{
float4 pos : SV_POSITION;
};

v2f vert(appdata v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}

如果你想把这段再拆开看,也完全可以显式走三步:

1
2
3
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
float4 viewPos = mul(UNITY_MATRIX_V, worldPos);
o.pos = mul(UNITY_MATRIX_P, viewPos);

这两种写法本质一样,只是后一种更适合调试“我到底在哪一段空间出了问题”。

八、为什么顶点着色器常常输出 SV_POSITION

因为顶点最终要交给光栅化阶段,必须先进入裁剪空间语义。

在 HLSL 里,通常会把经过 MVP 变换后的结果输出到 SV_POSITION

这不是随便起的字段名,而是图形管线约定好的“顶点最终裁剪位置”。

九、一个最常见的实战问题:明明世界坐标对了,屏幕上却不对

这类问题很常见,特别是在做:

  • 屏幕特效
  • 轮廓线
  • 深度相关着色
  • 世界空间贴图

根因通常是空间搞混。

比如:

  • 你以为自己在用世界坐标,实际还在模型坐标
  • 你以为法线在观察空间,实际还是对象空间
  • 你把裁剪空间值拿去和世界空间值直接比较

矩阵知识在这里的作用,不是让你手推公式,而是让你能快速定位:

我现在这份数据,究竟处在哪个空间。

十、为什么做 UI 跟随、屏幕投影时也会回到这条链

把 3D 物体头顶血条投到屏幕上,本质上也离不开这条变换链。

它只是把“模型顶点走进裁剪空间”的逻辑,换成“世界点走进屏幕坐标”的逻辑而已。

换句话说,MVP 不只是 Shader 内部知识,它其实和客户端很多屏幕空间功能是连着的。

十一、调试 Shader 空间问题时最实用的思路

不要一上来怀疑平台、精度、宏定义。

先问自己三个问题:

  1. 我当前这份坐标在哪个空间?
  2. 我要比较或使用的另一份数据在哪个空间?
  3. 这两份数据是否经过了同一条矩阵链?

大多数空间错乱问题,都是这三问里的某一个没对齐。

十二、总结

MVP 不只是图形学教材里的缩写,它是 Unity 渲染里最核心的一条坐标转换链。

理解它之后,你再看模型空间、世界空间、观察空间、裁剪空间,就不会是四个散词,而是一条连续流程。

下一篇我会继续讲一个更容易踩坑的地方:非等比缩放为什么会把法线搞坏,以及 normal matrix 到底解决了什么问题。