坐标空间、MVP 与透视投影
坐标空间、MVP 与透视投影:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能绘制从「局部空间 → 世界空间 → 观察空间 → 裁剪空间 → 屏幕空间」的完整管线,并说出每一步使用的矩阵
- 能推导 LookAt 矩阵的 front / right / up 三个正交轴,并写出摄像机位于 z 轴时的 View 矩阵
- 能解释裁剪坐标、透视除法与 NDC 的先后关系,判断一个点在哪一步会被裁掉
- 能比较正交投影与透视投影的可见体、w 分量和近大远小效果
- 能回答 near、far、fov 与 aspect 分别怎样改变视锥体,并分析错误参数导致的消失或变形
为什么一个点画到屏幕要经过五张「地图」
前面你已经能把三角形画出来了,也学会了用矩阵把它放大、旋转、平移。但真实的三维场景远不止「掰一下」。一个模型师在建模软件里雕出来的角色、一座从网上下载的地形——它们是怎么变成屏幕上那一帧画面的?
想象你在用卫星地图:地球是三维的球面,你拿手机看的是二维平面。从球面到平面,中间要一步步换「看世界的角度」——经纬度是一张图、平面投影是另一张图、屏幕像素又是第三张图。三维渲染也一样:每个物体经过五次换坐标系,从「用自己最舒服的坐标画自己」开始,一步步换到「用户屏幕上的像素停在哪个位置」为止。每一步乘一个矩阵,一共三个矩阵(Model / View / Projection),串在一起叫 MVP。
没有这套坐标系流水线,模型就不知道自己在世界里的位置、摄像机就不知道从哪拍、远处的山和近处的树就一样大——你看到的就不是三维,而是一张死板的二维平面图。
从局部到屏幕:五个空间的旅途
先看全景。下面这张图把顶点坐标在整个渲染管线里走过五个空间、乘三个矩阵的整趟旅途摊开:
一条线段总结:gl_Position = projection * view * model * vec4(aPos, 1.0)。乘的顺序从右往左——先 Model 把模型摆到世界、再 View 把世界拉到摄像机面前、最后 Projection 把可见范围压进 NDC 立方体。下面逐个空间掰碎。
局部空间:模型以自己为世界中心
建模师在 Blender 或 Maya 里雕一只猫时,不会关心这只猫最终会被你摆在场景的哪个位置、离摄像机多远。他只需要以猫自己为中心——猫的头朝什么方向、四条腿在什么高度,所有顶点坐标都用猫自己的 ↡每个模型自己定义自己的坐标系。原点通常是模型中心或脚底,坐标轴方向由建模软件决定。模型师在这个空间里造模型,完全不用关心它在世界里的位置、朝向、大小。 来写。同一个 .obj 文件里顶点坐标总是固定不变的——局部空间保证了「模型定义」这件事跟「摆在哪」完全解耦。
为什么需要它?如果每个模型都直接按世界坐标写顶点,换个位置就得重新导出模型文件,换一次就要命。局部空间让模型定义一次,后面靠矩阵随便摆。
世界空间:把所有模型摆到同一场上
要靠 ↡已知。把局部空间的顶点变换到世界空间所用的矩阵。缩放 + 旋转 + 平移组合,等于确定「这个模型被摆在世界的什么位置、朝什么方向、多大」。。这个矩阵和变换章 §4 推导的组合矩阵完全一样(),它把模型从局部空间变换到世界空间——所有物体共用一个原点、同一套坐标轴。
这本该叫「世界空间」,但因为 Model 矩阵和世界空间的「概念」上一章已经讲透了,这里一句带过,把篇幅留给更核心的 View 和 Projection。
观察空间:把世界拉到摄像机面前
世界空间里所有物体都摆好了,但画面得从某个角度去「拍」。↡一个以摄像机为原点的坐标系。摄像机自己处在原点 (0,0,0),它朝向的方向是 -z 轴(OpenGL 约定),右方是 +x,上方是 +y。这个空间里的坐标叫观察坐标,表示「从摄像机眼里看,这个点在哪」。 就是把世界空间里所有东西,整体挪动和旋转——直到摄像机变成新原点,摄像机面朝的方向变成 -z 轴(OpenGL 里约定观察方向为 -z)。
这一步靠的是 LookAt 矩阵(View 矩阵),输入只有三个数:摄像机站在哪、看向谁、哪个方向是「上」。矩阵推导在 §4。
裁剪空间:框定摄像机到底能「看见」多大范围
摄像机摆在观察空间原点、面朝 -z,但它不是 360° 全可以看到。相机有「视野宽度」(广角 / 长焦),也只看得到从镜头跟前一段距离到远处某个范围内、落在「画框」里的东西。这个「看得见的范围」就要靠投影矩阵来定义。↡投影矩阵把顶点变换到的空间。在这个空间里,所有看得见的顶点被装进 [-1,1]³ 的立方体——x∈[-1,1]、y∈[-1,1]、z∈[-1,1]。落在这个立方体外的部分会被 GPU 自动裁掉不画,因此得名「裁剪空间」。这个立方体也叫 NDC(归一化设备坐标)。 就是把视锥体里能看见的东西压进一个 的立方体,压不进去的一律裁掉不画。
投影分两种:
- 一种是 ↡一种平直投影——直接把一个长方体视野映射到 NDC 立方体。远近物体大小不变,没有「近大远小」。常用于 2D 渲染、UI、工程制图。:直接框一个长方体,里面东西平移到 NDC,没有近大远小(做 UI / 2D 常用)。
- 另一种是 ↡一种模拟人眼/相机镜头的投影——把一个金字塔形视锥体压进 NDC 立方体。离摄像机越远的物体被压得越小,产生「近大远小」效果。这正是三维真实感的关键。:框一个金字塔形的视锥体,远处的被压扁、近处的撑大——这就是「近大远小」的数学来源。
下面这张图把透视投影的视锥体和 NDC 的关系画出来:
NDC:管线里的「标准集装箱」
投影之后,所有顶点的坐标都落在 这个立方体里。这就是 ↡Normalized Device Coordinates,归一化设备坐标。顶点经投影矩阵和透视除法后落入 [-1,1]³ 的立方体。它是一个「标准中间格式」——不管屏幕分辨率是 1920×1080 还是 4K,GPU 在 NDC 里做裁剪、深度测试等操作都使用同一套坐标,最后一步视口变换才映射到实际像素。(归一化设备坐标)。
为什么需要它?不管你的屏幕是 1920×1080 还是 4K、不管摄像机看多远——GPU 需要一套统一的中间格式来做裁剪和深度判断。NDC 就是这个中间格式。透视投影做完后还不是 NDC——要再做一次「透视除法」(把 x/y/z 都除以 w)才真正落到 NDC,这一步 §4 细讲。
屏幕空间与深度缓冲
最后一步,NDC 里的 坐标按屏幕分辨率缩放,映射成屏幕上的像素坐标——这叫视口变换(由 gl.viewport 自动搞定)。但还有一个重要问题:当一个像素上同时有多个物体投过来(比如一块玻璃前有棵树后面的树梢也被画到了同一个像素),谁在前面?
靠 ↡一个和颜色缓冲同样大小的缓冲,每个像素存着「这个像素上已画过的最浅深度值(最靠近摄像机)」。新来的片段如果深度比已存的大(更远),就被丢弃不画;如果更小(更近),则覆盖颜色缓冲并更新深度缓冲。这就是「后面的东西被前面的挡住」的机制。——GPU 在画每个像素前,先查这个像素上之前画过的最浅深度值。新来的要是更远就直接跳过,更近则画上并更新深度。这就是「后面的东西被前面的挡住」的机制。
数学推导:投影矩阵与 LookAt 矩阵
这是本章的重心——一切「空间变换」最终都得落到矩阵上。本节推导两个投影矩阵和 LookAt 矩阵。先列符号表:
- :正交投影的左右下上
- :近裁面和远裁面距离(绝对值,正数)
- :fov 视角(透视投影,按弧度)
- :宽高比(aspect = 宽 / 高)
- :摄像机位置;:目标点
- :摄像机三根正交轴(front / right / up)
正交投影矩阵
正交投影最简单:视野是一个长方体,左右 、下上 、近远 。目标是把它平移 + 缩放进 :
这个式子在说:矩阵前三列对角线上放「缩放倍数」——把长方体的宽 / 高 / 深各自缩成 2( 的长度)。最后一列放「平移量」——把长方体中心挪到原点。注意 z 分量处是负数,因为 OpenGL 里观察方向沿 ,近的 z 值比远的大( 按代数值),矩阵翻转符号让深度正确装盒。当 、 时(左右对称、上下对称),前两个对角线退化为简单的 和 。
透视投影矩阵
透视投影比正交复杂:要把一个金字塔形视锥体压进 NDC 立方体。关键在两步:透视除法(除以 w 实现远小近大)+ 装盒(缩放到 )。
先看从视锥体「挤」成标准长方体的核心步骤。给定 fov 、宽高比 、近裁面 、远裁面 ,矩阵(列主序,推导从相似三角形和 NDC 装盒两步得到):
这个式子在说:第一行负责 x 的缩放——把视野宽度按 fov 和宽高比缩到 ;第二行同理缩放 y;第三行最核心——z 分量上, 在右下角之上一个格(这就是「把 塞进 w」),让透视除法时 等于观察空间里的 (即点到摄像机的距离)。最后一行的 正是这个「把距离写进 w」的设置——乘完后 w 分量变成 ,透视除法时 x/w、y/w 就自动实现了「离摄像机越远,x 和 y 被除数越大 → 越缩越小」的近大远小。
分开来看:乘完矩阵后,一个视锥体里的点 变成了:
这个式子在说:w 分量被填成了原始观察空间里的 (也就是点到摄像机的垂直距离)。接下来 GPU 自动做透视除法——把前三个分量都除以 w:
这就是透视的魔力。 和 正是「远处的东西除以更大的分母 → 结果更小 → 近大远小」。z 坐标经变换后不再是线性的——离近裁面近的点 z 精度高、离远裁面近的点 z 精度低(这正是后面「深度精度」那个坑的根因)。透视除法由 GPU 在顶点着色器之后、光栅化之前自动完成——你不需要手写。
LookAt 矩阵
View 矩阵的核心思路:把整个世界绕着摄像机平移再旋转,让摄像机变成新原点、面朝 。构建它只需要三个数:摄像机位置 ,看向的目标 ,世界的「上」方向。
构造三根正交轴:
步骤固定——①用 target 减 position 得出摄像机面朝的方向(归一化得前向轴 );②拿世界 up 跟 叉乘算出右轴 ;③再拿 跟 叉乘得到精确的上轴 。三根轴拼成旋转矩阵,加上平移,组装成 4×4 LookAt 矩阵(先平移让摄像机到原点、再旋转让三轴对齐坐标轴):
这个式子在说:矩阵左上 3×3 是旋转部分——把摄像机的右 / 上 / 前(取反,因为摄像机面朝 )三轴分别对齐到世界 x / y / z 轴。最后一列是平移部分——把摄像机位置 投影到这三根轴上的负值,效果就是「把世界往反方向挪,让摄像机坐进原点」。所有元素纯由 、、world up 三个输入算出来,一步到位。
动手:一步一步看一个点走完五张「地图」
下面是本章的主可视化——跟着一个模型局部空间里的顶点,一步步看它经 Model、View、Projection 三个矩阵变换后最终落在屏幕上的全过程:
第 0 步:局部空间
一个模型文件里的顶点(比如猫的左耳尖),坐标是 ——用模型自己的坐标系写的。此时世界在哪里、摄像机在哪里,全不知道。
猜一猜:把透视投影的 fov 从 45° 调到 120°,画面里的物体会变多还是变少、变大还是变小?再把 near 从 0.1 慢慢调到 10——物体什么时候突然消失?为什么?(提示:想想视锥体的形状和「落在 near 和 far 之间才能被看见」这条规则。)
代码对照:在着色器里拼装 MVP
概念和数学讲完了,落地的代码其实很简洁。顶点着色器里只需一句乘法——MVP 矩阵整个作为 uniform 传入:
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
// 三个矩阵原地乘好,或分开乘——结果相同
gl_Position = projection * view * model * vec4(aPos, 1.0);
}#version 300 es
layout (location = 0) in vec3 aPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
// 三个矩阵原地乘好,或分开乘——结果相同
gl_Position = projection * view * model * vec4(aPos, 1.0);
}CPU 侧负责构建三个矩阵。Model 用上一章的平移旋转缩放组合,View 用 LookAt,Projection 用透视投影:
// Model:把物体摆在 (0, 0, -5),绕 x 轴转 -55°
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(0.0f, 0.0f, -5.0f));
model = glm::rotate(model, glm::radians(-55.0f),
glm::vec3(1.0f, 0.0f, 0.0f));
// View:摄像机在 (0,0,3),看向 (0,0,0)
glm::mat4 view = glm::lookAt(
glm::vec3(0.0f, 0.0f, 3.0f),
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));
// Projection:透视,fov 45°,宽高比,近 0.1 远 100
glm::mat4 projection = glm::perspective(
glm::radians(45.0f),
(float)width / (float)height, 0.1f, 100.0f);// Model:把物体摆在 (0, 0, -5),绕 x 轴转 -55°
const model = mat4.create();
mat4.translate(model, model, [0, 0, -5]);
mat4.rotateX(model, model, (-55 * Math.PI) / 180);
// View:摄像机在 (0,0,3),看向 (0,0,0)
const view = mat4.create();
mat4.lookAt(view, [0, 0, 3], [0, 0, 0], [0, 1, 0]);
// Projection:透视,fov 45°,宽高比,近 0.1 远 100
const projection = mat4.create();
mat4.perspective(projection, (45 * Math.PI) / 180, width / height, 0.1, 100);两端思路一致:从单位矩阵起步,分别构建 Model / View / Projection,上传三个 uniform。注意三个关键 API 对应:
容易踩的坑
小结
- 顶点从模型文件到屏幕要走五个空间:局部 → 世界(Model) → 观察(View) → 裁剪(Projection) → NDC → 屏幕,三个矩阵串成 MVP
- View 矩阵(LookAt)靠摄像机位置 + 目标点 + world up 三个输入算出正交三轴,组装成「平移+旋转」矩阵,把世界拉到摄像机前面
- 正交投影直直框长方体进 ,远近一样大;透视投影把金字塔形视锥体挤压进 NDC 立方体,透视除法让「远的变小」——这就是近大远小的数学
- 透视投影矩阵把观察空间的深度 塞进 w 分量,GPU 自动除以 w 完成透视;z 缓冲精度非线性——near 设太小会吃掉远处的精度,导致 z-fighting
- 着色器里
gl_Position = P · V · M · vec4(aPos, 1.0)一行走完全程;代码端记得开深度测试(gl.enable(gl.DEPTH_TEST))
练习
问题 1(推导 / 手算题) 摄像机在 ,看向原点 ,world up 是 。①写出三个输入向量,手算出 front、right、upReal 三根轴。②按 §4 LookAt 矩阵公式写出完整的 4×4 View 矩阵。
问题 2(改 Demo 题) 在本章示例代码里做两件事:①把透视投影改成正交投影,观察场景里的物体大小变化——远方的物体和近处的物体大小还一样吗?②在正交投影下,把 near 参数从 0.1 改成 5,找出哪些物体会消失,解释为什么。
问题 3(裁剪排错题) 一个点经 MVP 后得到裁剪坐标 ;另一个点得到 。分别做透视除法,判断哪个点位于 NDC 的可见范围,并说明为什么不能在除以 w 之前直接拿 x 与 1 比较。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 局部空间
每个模型自己定义的坐标系。原点通常是模型中心或脚底,模型师在这个空间里造模型,完全不关心它在世界里的位置、朝向、大小。同一个模型文件不管摆在哪,局部坐标永远不变。详见本章「局部空间」一节。
- 世界空间
所有物体共用的全局坐标系。每个模型经 Model 矩阵变换后摆进这个世界,跟其他物体共享同一个原点和坐标轴。就像把玩具一个个摆到同一张桌子上。详见本章「世界空间」一节。
- 模型矩阵(Model)
把模型从自己的局部空间摆进世界空间的 4×4 矩阵,通常由缩放、旋转、平移按
T·R·S组合而成。它只决定物体在世界中的大小、朝向和位置,不负责摄像机与投影。- 观察空间
以摄像机为原点的坐标系。摄像机自己站在原点 (0,0,0),面朝的方向是 -z 轴(OpenGL 约定),右方是 +x,上方是 +y。这个空间里所有坐标表示「从摄像机眼里看,这个点在哪」。由 LookAt 矩阵(View)把世界变换过来。详见本章「观察空间」一节与「LookAt 矩阵」推导。
- 裁剪空间
投影矩阵把顶点变换到的空间。在这个空间里,看得见的顶点被装进 [-1,1]³ 的立方体——落在外面的部分被 GPU 自动裁掉不画。透视投影下,这一步还包含了透视除法的前置准备(把深度塞进 w)。详见本章「裁剪空间」一节。
- NDC
Normalized Device Coordinates,归一化设备坐标。顶点经投影矩阵和透视除法后最终落入 [-1,1]³ 的立方体。GPU 在 NDC 里做裁剪、深度测试等操作,最后一步视口变换才映射到屏幕实际像素。详见本章「NDC」一节。
- 视锥体
透视投影的可见区域——一个从摄像机出发的金字塔形四棱台:近裁面(near)是金字塔小平顶、远裁面(far)是金字塔大底。落在视锥体棱线内的物体会被渲染,之外的被裁剪。fov(视角)决定了金字塔的张开程度。详见本章「裁剪空间」一节与 FrustumDiagram。
- 正交投影
一种平直投影——直接把一个长方体视野内的东西映射到 NDC 立方体。远近物体大小不变,没有「近大远小」。常用于 2D 渲染、UI、工程制图。矩阵比透视投影简单得多:就是缩放到 [-1,1]³ 加居中平移。详见本章「正交投影矩阵」推导。
- 透视投影
一种模拟人眼和相机镜头的投影——把一个金字塔形视锥体里的内容挤压进 NDC 立方体。投影矩阵把观察空间的深度 塞进 w 分量,透视除法除以 w 后实现「离摄像机越远,x 和 y 被除数越大 → 越缩越小」的近大远小。这是三维真实感的核心。详见本章「透视投影矩阵」推导。
- 深度缓冲
和颜色缓冲同样大的隐藏缓冲,每个像素存着「这个像素上已画过的最浅深度值」。新片段如果更深(更远)就被丢弃不画;更浅(更近)则覆盖颜色同时更新深度。这就是「后面的东西被前面的挡住」的 GPU 机制。使用时需要手动
gl.enable(gl.DEPTH_TEST)开启。详见本章「深度缓冲」一节与 §7 误区。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- model matrix:在“坐标空间、MVP 与透视投影”中由model/view/projection 三矩阵与固定功能 viewport/depth 映射负责解释其输入、受控状态和可观察结果;运行时以五空间坐标、clip.w、NDC、viewport、深度值与基准截图定位它的第一处变化。
- view matrix:在“坐标空间、MVP 与透视投影”中由model/view/projection 三矩阵与固定功能 viewport/depth 映射负责解释其输入、受控状态和可观察结果;运行时以五空间坐标、clip.w、NDC、viewport、深度值与基准截图定位它的第一处变化。
- projection matrix:在“坐标空间、MVP 与透视投影”中由model/view/projection 三矩阵与固定功能 viewport/depth 映射负责解释其输入、受控状态和可观察结果;运行时以五空间坐标、clip.w、NDC、viewport、深度值与基准截图定位它的第一处变化。
- perspective:在“坐标空间、MVP 与透视投影”中由model/view/projection 三矩阵与固定功能 viewport/depth 映射负责解释其输入、受控状态和可观察结果;运行时以五空间坐标、clip.w、NDC、viewport、深度值与基准截图定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“更新 MVP,执行透视除法,并把 NDC 映射到 framebuffer viewport”发生后,model/view/projection 三矩阵与固定功能 viewport/depth 映射应怎样改变对象姿态、相机坐标、frustum、clip.w、NDC 和窗口深度;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
坐标空间、MVP 与透视投影:状态合同
沿 model→world→view→clip→NDC→viewport 追踪一个顶点并核对透视参数
验证场景
官方教程正式概念
logl-07 · 基线帧
model matrix:固定 context、资源内容与输入事件,执行“更新 MVP,执行透视除法,并把 NDC 映射到 framebuffer viewport”
冻结输入:model matrix
model/view/projection 三矩阵与固定功能 viewport/depth 映射记录对象姿态、相机坐标、frustum、clip.w、NDC 和窗口深度
结果:得到可重复的初始 GL 状态与资源身份
观测:五空间坐标、clip.w、NDC、viewport、深度值与基准截图中的初始快照
预期:model/view/projection 三矩阵与固定功能 viewport/depth 映射得到可复查结果,并持续满足“所有矩阵使用同一坐标/手性约定,aspect 来自当前 framebuffer 宽高”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“更新 MVP,执行透视除法,并把 NDC 映射到 framebuffer viewport”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“所有矩阵使用同一坐标/手性约定,aspect 来自当前 framebuffer 宽高”。
CPU command · GL state · GPU result
坐标空间、MVP 与透视投影:五段轨迹
当前观测:五空间坐标、clip.w、NDC、viewport、深度值与基准截图中的初始快照
不变量:所有矩阵使用同一坐标/手性约定,aspect 来自当前 framebuffer 宽高
实验三:单故障与同输入恢复
注入“窗口横向放大后 projection 仍用旧 aspect,圆形被拉成椭圆”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有五空间坐标、clip.w、NDC、viewport、深度值与基准截图一起恢复才算修复。
Single fault · first divergence · replay
坐标空间、MVP 与透视投影:反例与恢复
故障:窗口横向放大后 projection 仍用旧 aspect,圆形被拉成椭圆
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“窗口横向放大后 projection 仍用旧 aspect,圆形被拉成椭圆”
所有矩阵使用同一坐标/手性约定,aspect 来自当前 framebuffer 宽高
五空间坐标、clip.w、NDC、viewport、深度值与基准截图
最小可重放检查
unit: logl-07
owner: model/view/projection 三矩阵与固定功能 viewport/depth 映射
state_or_resource: 对象姿态、相机坐标、frustum、clip.w、NDC 和窗口深度
command: 更新 MVP,执行透视除法,并把 NDC 映射到 framebuffer viewport
pass_invariant: 所有矩阵使用同一坐标/手性约定,aspect 来自当前 framebuffer 宽高
single_fault: 窗口横向放大后 projection 仍用旧 aspect,圆形被拉成椭圆
required_evidence: 五空间坐标、clip.w、NDC、viewport、深度值与基准截图复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。