把点投影到裁剪空间(Clip Space),本质上就是:
把“从摄像机看到的那块三维视锥世界”,压扁变成一个规则的“标准盒子”,
方便 GPU 后面统一裁剪、归一化、映射到屏幕。
听着很玄,其实特别生活化:
- 你眼前真实的世界:大小不一、远近不同、形状乱七八糟;
- GPU 比较懒,喜欢处理“统一格式”的数据;
- 于是,它想办法把“能看到的那块空间”捏成一个标准尺寸的长方体:
- 中心是 0,
- 左右上下前后就是 -1 到 1(一种约定),
- 所有看得见的点都被塞进这个盒子里;
- 超出盒子之外的,直接剪掉(裁剪);
- 盒子内部再往屏幕上“摊平”,就成了一张图。
这篇文章,我们就用大白话,把下面这几个问题讲透:
- 裁剪空间(Clip Space)到底是个啥?
- 为什么非要把三维世界变成一个“标准盒子”?
- 投影矩阵(Projection Matrix)在整个过程里干了啥?
- 那个看起来很怪的
w分量,以及“除以 w(透视除法)”是在干嘛? - 点是怎么一步步从世界 → 摄像机 → 裁剪空间 → NDC → 屏幕的?
- GPU 在 Clip Space 里做了哪些操作(裁剪、可见性)?
- 实际写 Shader / 引擎时,你会怎么接触到 Clip Space?
看完之后,你在脑子里看到“Clip Space / 投影矩阵 / gl_Position”这些词时,
就不会觉得是在念咒,而是知道它们到底在做哪一步“压盒子”的事。
一、先回顾一下:我们为什么会走到“裁剪空间”这一步?
把坐标系整个链条先捋一遍:
模型空间(Model / Local Space)
- 模型自己家里的坐标,只描述它长啥样。
- 比如立方体顶点:(-1,-1,-1) ~ (1,1,1)。
世界空间(World Space)
- 整个游戏世界统一的坐标系。
- 所有物体摆到同一张地图上。
摄像机空间(View / Camera Space)
- 以摄像机为原点和坐标轴的坐标系。
- 所有东西变成“离相机前后左右上下多少”。
到这里,我们已经有了:
“以相机为中心、能看到的一块三维区域”——视锥体(Frustum)。
接下来要做两件事:
- 把这块“视锥体”的三维点,投影成一个规则的“标准盒子”:
- 这个标准盒子就是裁剪空间(Clip Space,做透视除法前);
- 然后再做除以 w,变成 [-1,1] 的归一化坐标:
- 叫NDC(Normalized Device Coordinates,归一化设备坐标)。
这一整套主要由一个矩阵负责:
投影矩阵(Projection Matrix)。
二、先形象一点:视锥体 VS 标准盒子
2.1 视锥体是个啥?
想象你拿着一个相机:
- 你眼前能看到的空间,大致像一个“前面开的锥形截头体”:
- 离你太近的(摄像机前面的一小块)看不到(被 near plane 裁掉);
- 离你太远的也看不到(被 far plane 裁掉);
- 左右、上下有一个 FOV(视野角)。
这块空间叫做:视锥体(View Frustum)。
形状大概像这样(侧视图):
/| / | 相机/ | 可见范围 \ | \ | \|近裁剪面 + 远裁剪面 + 左右上下 4 个平面,一共 6 个平面包围着这一块空间。
2.2 Clip Space 要做的事情:把这个不规整的锥体 → 变成标准长方体
GPU 数学家们有一个习惯:
不喜欢直接处理“斜着的、不规整的锥体”,
喜欢统一把东西变成一个标准的长方体(盒子)。
为什么?
- 规则盒子更好判断点在不在里面:
- 只要 x、y、z 在某个范围内就行;
- 更适合硬件里一套固定的裁剪逻辑;
- 为后续的归一化、屏幕映射打基础。
所以,投影矩阵干的第一件大事就是:
把摄像机空间里的视锥体“拉伸 + 扭曲”成一个标准长方体,
这个长方体对应的坐标,就是剪裁空间(Clip Space)。
三、Clip Space 是个什么样子的“标准盒子”?
在大多数学库/API 中,Clip Space 是一个用齐次坐标表示的四维空间:
- 一个点在 Clip Space 中通常写成
(x, y, z, w); - 后面会用
x/w, y/w, z/w得到 NDC(归一化设备坐标)。
在做“判断点在不在可见空间里面”之前,Clip Space 中有这么一个约定:
在透视除法之前,点要满足:
-w ≤ x ≤ w
-w ≤ y ≤ w
以及 z 与 w 的某些范围关系(比如 -w ≤ z ≤ w,或 0 ≤ z ≤ w)。
简单说,就是一个“以原点为中心,边界是 ±w 的四维盒子”。
虽然听起来抽象,但你可以这样理解:
- 把 w 当成一个“缩放参考值”;
- 当前帧能看见的东西,都被挤压到这个“x,y,z ∈ [-w, w]”的区域内;
- 之后通过
/w把这个盒子缩成 [-1,1] 的标准立方体(NDC)。
所以:
- Clip Space = 投影矩阵刚变换完的、还没除以 w 前的空间;
- NDC = Clip Space 除以 w 之后的 [-1,1] 标准盒子。
四、投影矩阵(Projection Matrix):负责“压盒子”的核心角色
我们先不用公式吓自己,用直觉描述投影矩阵要做的三个事:
- 让远处的东西看起来更小(透视);
- 把视野角、宽高比、近远平面这些乱七八糟的参数,编码进坐标变换中;
- 把不规整的“斜锥”变成规整可控的“盒子”。
4.1 透视投影 vs 正交投影
一般游戏里常用两种投影方式:
透视投影(Perspective Projection)
- 模拟人眼 / 相机的效果:
- 近大远小;
- 适合 3D 场景。
- 模拟人眼 / 相机的效果:
正交投影(Orthographic Projection)
- 不考虑距离引起的缩放,远处物体大小不变;
- 看起来像工程图或某些 2D / 策略游戏的效果;
- 用于 UI、某些编辑器视图。
这两种投影对应不同的 Projection 矩阵,
但它们都完成了“把视锥(或视体)映射到标准盒子”的任务。
4.2 透视投影:近大远小是怎么“装”进矩阵里的?
在 Camera Space 中:
- 一个点的坐标是
(x, y, z); - 平常的直觉是:离相机越远(|z|越大),这个点投影到屏幕上应该越接近中心、越小。
简单的透视关系像这样:
x_screen ∝ x / z y_screen ∝ y / z也就是说:
- x 和 y 需要除以 z,才能体现近大远小。
但是我们的矩阵变换喜欢做的是:
“线性变换”:M * (x,y,z,1),
而/z是个非线性的操作。
怎么办?
人类想出一个骚操作:引入第四个分量 w,用齐次坐标。
投影矩阵会把 Camera Space 坐标(x,y,z,1)变成:
ClipPos = (x', y', z', w')并且设计成:
x’ 和 y’ 里面“偷偷包含了 z 的信息”,
使得最后:
ndc.x = x' / w' ndc.y = y' / w'刚好就等价于之前那个x / z,y / z的透视关系。
大白话:
投影矩阵把“除以 z”这件事,藏进了“除以 w”的步骤里。
所以你在代码里只看到clipPos = Proj * viewPos; ndc = clipPos / clipPos.w;
而透视效果就在/w那一下完成了。
4.3 近远平面、视野角、宽高比怎么藏进矩阵?
投影矩阵的参数一般包括:
fov(Field of View):视野角;aspect:宽高比(screenWidth / screenHeight);near:近裁剪面距离;far:远裁剪面距离。
这些参数会影响:
- x,y 的缩放范围(视野越大,x,y 范围越宽);
- z 的映射(如何把 [near,far] 映射到 Clip Space 的 z范围,再到 NDC,再到深度缓冲的数值)。
你不用背公式,只要记住:
投影矩阵根据 fov、aspect、near、far,
决定“视锥体的形状”和“如何把它压成盒子”。
五、那个神秘的 w 分量 & 透视除法(Perspective Divide)
现在我们来正面聊一聊大家最容易迷糊的地方:齐次坐标和 w。
5.1 为啥要多一个 w?原来的 (x,y,z) 不够用吗?
如果我们只用 3 维坐标(x,y,z)做线性变换:
vec3' = M3x3 * vec3 + t它能表达的是:
- 平移、旋转、缩放、剪切、线性变换等等;
- 但没办法用一个矩阵统一表达“透视投影”这种带除法的非线性操作。
引入齐次坐标(x,y,z,w)后:
- 可以把各种仿射变换、投影变换统一表示成一个 4x4 矩阵乘法;
- 然后统一通过一次
/w完成以前那些“看起来非线性”的缩放。
你可以用一个很粗糙但好记的比喻:
w 就像是一个“还没结算的缩放因子”,
先把所有变换放在 (x,y,z,w) 里,
最后一次除以 w来“结账”,
得到最终的 3D 坐标。
5.2 Clip Space 到 NDC:透视除法
在顶点着色器里,你经常会看到这样的代码(以 OpenGL 为例):
gl_Position = ProjectionMatrix * ViewMatrix * ModelMatrix * vec4(localPos, 1.0);这里的gl_Position就是 Clip Space 坐标(x', y', z', w')。
GPU 在后续阶段会自动做:
ndc.x = gl_Position.x / gl_Position.w; ndc.y = gl_Position.y / gl_Position.w; ndc.z = gl_Position.z / gl_Position.w;这一行,叫做:透视除法(Perspective Divide)。
除完以后:
(ndc.x, ndc.y, ndc.z)一般都落在 [-1,1] 的范围内(在视野里的点);- 这些点就落到了所谓的 NDC 空间(归一化设备坐标)。
远处的点:z 大,w 也会按设计变大,导致 x/w、y/w 更接近 0——
于是被投影到屏幕中心附近,看上去更小;
近处的点:z 小,比例更大,被投影到屏幕边缘更开阔的位置,看起来更大。
透视感的本质,就体现在这一步/w上。
六、Clip Space 到 NDC,再到屏幕:盒子缩小 → 平铺到屏幕
我们已经知道:
- Clip Space:
(x', y', z', w'); - 透视除法后:NDC,
(x/w, y/w, z/w)。
6.1 NDC:一个固定 [-1,1] 的标准立方体
NDC 中:
x ∈ [-1, 1]:
- -1 = 左边界
- 1 = 右边界
y ∈ [-1, 1]:
- -1 = 下边界(OpenGL) 或上边界(视 API 而定);
- 1 = 上边界或下边界。
z ∈ [-1, 1] 或 [0, 1](视 API 而定):
- 表示深度,最终会映射到深度缓冲里。
此时我们的三维世界,被统一压到了一个“单位立方体”里。
6.2 再从 NDC 到屏幕像素
最后一步:把 [-1,1] 的立方体投影到实际屏幕的像素坐标系(0 ~ width, 0 ~ height)。
示意公式(以左下角为 (0,0) 为例):
screenX=(ndc.x*0.5f+0.5f)*ScreenWidth;screenY=(ndc.y*0.5f+0.5f)*ScreenHeight;如果你的屏幕 y 轴向下,则可能是:
screenY=(1.0f-(ndc.y*0.5f+0.5f))*ScreenHeight;到这一步,我们终于从:
模型空间点 → 世界空间 → 摄像机空间 → Clip Space → NDC → 屏幕像素
一路走到底,完成了“从 3D 点到 2D 像素”的整个投影过程。
七、GPU 在 Clip Space 里做啥?“裁剪”的重点来了
名字叫Clip Space,
“Clip” 就是 “裁剪、截断” 的意思。
7.1 裁剪啥?
很简单:
裁剪那些“在视野外”的部分——
也就是超出标准盒子范围的点和三角形。
在 Clip Space(或者 NDC)里:
- 如果一个顶点的坐标在 [-w, w](或 [-1,1])之外,说明在视野外;
- GPU 会自动对这些三角形做裁剪:
- 完全在外面的直接丢弃;
- 一部分在内、一部分在外的,会在边界上“切一刀”,只保留可见部分。
这样就有两个好处:
- 后面光栅化阶段,就不会为看不到的区域生成片元(像素),节省大量计算;
- 裁剪逻辑在硬件中是高度优化的,基于 Clip Space 统一处理。
7.2 为什么不在世界空间里裁剪?
理论上也可以直接在世界空间中用视锥平面来裁剪,但那样:
- 不同摄像机视野形状不同,数学复杂度高;
- 硬件实现上难以统一和优化。
而 Clip Space / NDC 的好处是:
已经把“各种各样的视锥体”压成了“统一的标准盒子”,
所以裁剪就是检查 ±1/±w 的简单范围问题。
GPU 的设计就是围绕这个“标准盒子”来的。
八、用一个完整的小故事再走一遍:一个点的一生
我们来给一个 3D 点编一个“人生经历”,帮助你把整个流程串成一条线。
场景设定
- 模型:一辆车,模型空间下顶点之一是 LocalPos = (1,0,0);
- 这辆车放到世界空间的 (10,0,50),略微旋转了一下;
- 摄像机在世界位置 (0,3,0),看向世界 +Z;
- FOV = 60°,近裁剪 0.1,远裁剪 100。
1. 模型空间(Local)
LocalPos = (1,0,0) 表示:
在车的模型内部,这个顶点是“右侧边上某一点”。
2. 世界空间(World)
WorldPos=ModelMatrix*LocalPos;ModelMatrix 根据车的位置 / 旋转 / 缩放生成。
得到 WorldPos,比如:
(10.8, 0.2, 50.5)
含义:
在整个游戏世界地图中,这个点在 (10.8, 0.2, 50.5)。
3. 摄像机空间(View / Camera)
ViewPos=ViewMatrix*WorldPos;ViewMatrix 把世界坐标变到“以摄像机为原点”的坐标系中。
假设得到:
ViewPos = (vx, vy, vz)
比如 (1.5, -0.3, 20.0)
含义:
从摄像机眼睛看出去,这个点在右边 1.5、下面 0.3、前方 20 距离处。
4. Clip Space(透视投影前)
ClipPos=ProjectionMatrix*ViewPos;// 得到 (cx, cy, cz, cw)投影矩阵把视锥体压成“标准盒子”。
ClipPos 可能是:
(30, -6, 19, 20)
暂时不要管数值,只知道:
- x,y,z,w 内部都编码了“远近位置 + 视野边界”的信息。
5. 透视除法:变成 NDC
ndc.x=30/20=1.5ndc.y=-6/20=-0.3ndc.z=19/20=0.95明显 ndc.x = 1.5 超出 [-1,1],
说明这个点(或所在三角形的一部分)已经在屏幕右侧外面了。
根据三角形其他顶点位置,GPU 会决定如何裁剪。
如果是另外一个点,ndc 落在 [-1,1] 内,
那么就表示在可见范围中。
6. 映射到屏幕像素
假设 ndc 在 [-1,1] 内,
再做:
screenX=(ndc.x*0.5+0.5)*width;screenY=(1-(ndc.y*0.5+0.5))*height;最终得到一个(screenX, screenY),
那个像素就是这辆车这个顶点投影到屏幕上的位置。
九、工程实践中:你实际“看见 / 使用” Clip Space 的地方
9.1 顶点着色器里的 gl_Position / SV_Position
在 OpenGL / GLSL 中:
gl_Position = Projection * View * Model * vec4(localPos, 1.0);这里的gl_Position就是Clip Space 坐标。
在 HLSL / DirectX 中,类似:
float4 clipPos = mul(Proj, mul(View, mul(Model, localPos))); return clipPos; // 语义 SV_Position这个返回值被标记为SV_Position,
就是裁剪空间中的位置。
你不需要手写“除以 w”,GPU 会在后续针对每个顶点统一做。
9.2 手写投影矩阵的情况(不常见,但有时需要)
大部分时候你会用引擎 API:
- Unity:
Matrix4x4.Perspective(fov, aspect, near, far) - DirectX:
XMMatrixPerspectiveFovLH - GLM(C++):
glm::perspective(...)
但有时候你需要更高级玩法(比如自定义投影、非对称视锥),
就得自己构造 Projection Matrix,
这时理解 Clip Space 的“标准盒子”就很重要了:
你要知道你的矩阵最后要把点变成哪种范围的 box,
才能和 GPU 默认裁剪逻辑对得上。
9.3 做反向推导:从屏幕坐标 / NDC 反推回世界坐标
一些高级效果需要你从屏幕坐标 / 深度信息反推 3D 位置,例如:
- 屏幕空间反射(SSR);
- 屏幕空间环境光遮蔽(SSAO);
- 根据点击屏幕位置射线选中 3D 对象(鼠标 picking)。
这些都需要你:
- 把屏幕坐标 → NDC(反向 viewport);
- 把 NDC → Clip Space(乘以 w 或构造一个适当的 clipPos);
- Clip → View(乘以投影矩阵的逆);
- View → World(再乘 View 矩阵的逆)。
这整个反向链路,正是建立在你理解 Clip Space 和 Projection 的基础上。
十、最后,用几句话把“投影到裁剪空间”压缩在脑子里
我们再用特别大白话的方式,把这个概念彻底钉牢:
摄像机空间里有一个“视锥体”:就是你能看到那一块 3D 空间。
投影矩阵做的事:
- 把这个不规整的“视锥体”压扁、拉伸,
- 变成一个统一标准的“盒子”(Clip Space);
- 顺便把透视(近大远小)、视野角、近平面/远平面这些都编码进去。
Clip Space 是变换后的中间状态:
- 点用齐次坐标
(x,y,z,w)表示; - 还没进行
/w的透视除法; - 但已经具备了“用 ±w 来判断是否在视野内”的结构。
- 点用齐次坐标
透视除法(除以 w) → NDC:
ndc = clipPos / clipPos.w- 这一步实现了近大远小,把整个可见空间变成 [-1,1] 的标准立方体(NDC)。
GPU 在 Clip/NDC 空间里做裁剪:
- 超出边界的三角形部分被剪掉;
- 只留下盒子里面的部分进入光栅化阶段。
最后从 NDC 映射到屏幕像素:
- 再把 [-1,1] 映射到 [0,width]/[0,height],
- 每个点就成了屏幕上的一个像素位置。
如果用一句特别形象的话总结:
“投影到裁剪空间”,
就是把你眼前那块立体的视野世界,
压成一个 GPU 最爱处理的“标准盒子格式”,
然后再从这个盒子里剪剪剪,
剩下的部分摊平成屏幕上的一帧画面。
以后你再看到:
gl_Position = Projection * View * Model * vec4(localPos, 1.0);就可以在心里翻译成:
“好,现在我要把这个 3D 点一路送到那个标准视锥盒子里去,
方便 GPU 帮我裁剪、投影、画到屏幕上。”