核心作用
投影矩阵,Projection Matrix,作用是把摄像机坐标系里的点,变换到裁剪空间。- 它决定的是"怎么看"里的最后一步规则,不是物体的位置,也不是相机朝向。
- 它主要控制这几件事:
视野范围有多大近处和远处怎么显示是否有近大远小哪些点会被裁剪掉
在顶点着色器里通常是:
glsl
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aPosition, 1.0);
这里的 uProjectionMatrix,就是投影矩阵。
先建立直觉
模型矩阵:把物体从局部空间放到世界里。视图矩阵:把世界变到相机空间里。投影矩阵:把相机空间里的 3D 点,压缩成适合屏幕显示的一套坐标。
你可以把投影矩阵理解成"相机镜头规则"。
同样一个场景:
- 镜头广角,看到的范围更大
- 镜头长焦,看到的范围更窄
- 近处东西大,远处东西小
- 超出视野的点不显示
这些,本质都和投影矩阵有关。
投影矩阵要解决什么问题
在摄像机空间里,一个点还是 3D 的:
text
(x, y, z)
但屏幕最终是 2D 的。 所以图形管线必须做两件事:
- 决定哪些点在"可见空间"里
- 把 3D 点映射到最终能落在屏幕上的范围
投影矩阵就是做这个映射的关键步骤。
WebGL 里常见的两种投影
投影矩阵通常有两类:
透视投影,Perspective Projection正交投影,Orthographic Projection
它们的区别非常重要。
1. 透视投影
- 有
近大远小 - 更符合人眼和相机
- 适合 3D 场景、游戏、漫游、建筑可视化
2. 正交投影
- 没有近大远小
- 远近物体看起来一样大
- 适合 CAD、编辑器视图、2D/3D 混合界面、工程图
如果你做的是一般 WebGL 3D 场景,最常用的是透视投影矩阵。
投影矩阵之后会发生什么
顶点在经过投影矩阵后,先进入裁剪空间。
然后 GPU 会做一个很关键的步骤:
text
NDC = clip.xyz / clip.w
这一步叫透视除法。
得到的 NDC,Normalized Device Coordinates,通常范围是:
text
x: [-1, 1]
y: [-1, 1]
z: [-1, 1] // WebGL / OpenGL 体系
之后再经过视口变换,才会真正映射到屏幕像素。
所以投影矩阵不是直接把点变成屏幕坐标,而是先把点送入一个标准化流程,后续由GPU自动完成。
为什么透视投影会"近大远小"
这是理解投影矩阵的关键。
假设一个点离摄像机越远,它在屏幕上的投影应该越小。 这意味着:
x和y不能只是简单线性变换- 它们要和深度
z产生关系
透视投影的核心直觉就是:
text
x' ≈ x / z
y' ≈ y / z
也就是:
- 越远,除得越多,看起来越小
- 越近,除得越少,看起来越大
矩阵本身先把这个"除以 z"的效果编码进 w 然后交给后面的透视除法完成。
所以你可以把透视投影矩阵理解成:
- 先准备好一个特殊的
w - 再通过
x/w、y/w实现远近缩放
透视投影矩阵由哪些参数决定
最常见的透视投影矩阵由 4 个核心参数控制:
fov:垂直视野角度aspect:宽高比near:近平面far:远平面
分别解释一下。
fov
- 视野角度,通常是竖直方向视野
- 值越大,看到范围越广,透视感更强
- 值越小,像长焦镜头,看到范围更窄
常见值:
45°60°75°
aspect
- 画布宽高比
- 通常是:
js
const aspect = canvas.clientWidth / canvas.clientHeight;
- 如果这个值不对,画面会被拉伸
near
- 近平面,离相机最近的可见距离
- 小于这个距离的点会被裁剪掉
- 必须大于 0
far
- 远平面,离相机最远的可见距离
- 超过这个距离的点会被裁剪掉
什么是近平面和远平面
你可以把摄像机前方可见空间想成一个截头锥体,叫视锥体,frustum。
这个视锥体:
- 近处有一个矩形面,叫
近平面 - 远处有一个更大的矩形面,叫
远平面
只有落在这个视锥体内的点,才有机会被显示。
所以投影矩阵其实也是在定义这个可见范围。
透视投影矩阵的直观结构
如果采用 WebGL / OpenGL 常见约定,透视投影矩阵通常长这样:
text
| f/aspect 0 0 0 |
| 0 f 0 0 |
| 0 0 (far+near)/(near-far) -1 |
| 0 0 (2*far*near)/(near-far) 0 |
这里:
text
f = 1 / tan(fov / 2)
先别急着背公式,先看每块在干什么。
- 左上角控制
x缩放 - 第二行控制
y缩放 - 后两行控制深度映射和透视除法
公式里每一部分是什么意思
f = 1 / tan(fov / 2)
fov越大,tan(fov/2)越大,f越小f越小,投影后的x/y被压缩得越少- 结果就是看到范围更大
也就是说:
- 大
fov,广角 - 小
fov,长焦
为什么 f 还要除以 aspect
因为屏幕通常不是正方形。 如果不考虑宽高比:
- 宽屏画面会变形
- 圆会被拉成椭圆
所以横向要额外做一次补偿:
text
f / aspect
深度部分为什么复杂
因为投影不仅要决定远近缩放,还要把摄像机空间里的 z 映射到后面标准化和深度测试使用的范围。 这部分会同时影响:
- 裁剪
- 深度缓冲
- 远近精度分布
所以公式看起来会比 x/y 复杂。
怎么手写一个透视投影矩阵
下面给你一个常见、可直接用的实现思路。
js
function perspective(fovRad, aspect, near, far) {
const f = 1.0 / Math.tan(fovRad / 2);
const nf = 1 / (near - far);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, (2 * far * near) * nf, 0,
]);
}
使用方式:
js
const fov = 60 * Math.PI / 180;
const aspect = canvas.clientWidth / canvas.clientHeight;
const near = 0.1;
const far = 1000;
const projectionMatrix = perspective(fov, aspect, near, far);
gl.uniformMatrix4fv(uProjectionMatrixLocation, false, projectionMatrix);
这个函数每一步在做什么
fovRad:把角度转成弧度后传入f:控制视野开口大小aspect:修正横向比例near/far:定义视锥体前后范围- 返回的
Float32Array(16):就是传给 shader 的mat4
如果 fov 改变,画面会怎样
fov 变大
- 看到的范围更广
- 透视变形更强
- 边缘拉伸感更明显
- 像广角镜头
fov 变小
- 看到的范围更窄
- 画面更"压缩"
- 像长焦镜头
所以很多 3D 场景里:
- 默认相机常用
45° ~ 75° - 第一人称有时更大
- 产品展示可能更克制
如果 near 和 far 设得不好,会怎样
这部分非常重要,很多初学者会忽略。
near 太小 比如:
js
near = 0.0001;
问题:
- 深度缓冲精度会很差
- 容易出现
Z-Fighting,也就是表面闪烁、重叠抖动
far 太大 比如:
js
far = 1000000;
问题:
- 深度精度同样会被摊薄
- 远近物体深度判断变差
工程经验:
near尽量不要过小far尽量不要过大- 两者差距越夸张,深度精度越容易出问题
常见合理值:
js
near = 0.1;
far = 1000;
如果场景比较小:
js
near = 0.5;
far = 100;
为什么 near 不能等于 0
因为透视投影本质上和"除法"有关。 当近平面设为 0 时:
- 数学上会出问题
- 投影矩阵中的某些项会失效或发散
- 图形管线也不允许这样做
所以:
text
near > 0
这是硬规则。
正交投影矩阵是什么
如果你不想要近大远小,就用正交投影矩阵。
它更像是一个规则盒子:
- 不按距离缩小
- 所有平行线保持平行
- 更适合工程场景和 UI
正交投影通常由这几个参数决定:
leftrightbottomtopnearfar
意思是定义一个长方体可见范围。
正交投影矩阵的常见形式
text
| 2/(r-l) 0 0 0 |
| 0 2/(t-b) 0 0 |
| 0 0 -2/(f-n) 0 |
| -(r+l)/(r-l) -(t+b)/(t-b) -(f+n)/(f-n) 1 |
你不需要现在强背它,先知道它的特点:
- 没有透视缩小
- 本质是把一个盒子线性映射到 NDC 范围
手写正交投影矩阵
js
function orthographic(left, right, bottom, top, near, far) {
const lr = 1 / (right - left);
const bt = 1 / (top - bottom);
const nf = 1 / (near - far);
return new Float32Array([
2 * lr, 0, 0, 0,
0, 2 * bt, 0, 0,
0, 0, 2 * nf, 0,
-(right + left) * lr,
-(top + bottom) * bt,
(far + near) * nf,
1,
]);
}
比如:
js
const projectionMatrix = orthographic(-10, 10, -10, 10, 0.1, 100);
这表示:
- 在摄像机前方定义一个固定大小的可见盒子
- 盒子里的东西都按线性比例投到屏幕上
透视投影和正交投影怎么选
用透视投影
- 真实 3D 场景
- 漫游相机
- 产品展示
- 游戏镜头
- 建筑空间浏览
用正交投影
- 2D UI
- CAD
- 地图
- 编辑器辅助视图
- 需要精确尺寸对比的场景
投影矩阵和摄像机有什么关系
投影矩阵本身不关心相机站在哪,也不关心相机朝哪。 这些是视图矩阵负责的。
投影矩阵关心的是:
- 这个相机的镜头参数是什么
- 这个相机的可见体积怎么定义
所以:
viewMatrix管位置和方向projectionMatrix管镜头和裁剪规则
结合完整渲染链路理解
一个顶点会经历:
text
localPosition
-> modelMatrix
-> worldPosition
-> viewMatrix
-> cameraPosition
-> projectionMatrix
-> clipPosition
-> perspective divide
-> NDC
-> viewport transform
-> screen
所以投影矩阵是"3D 进入标准屏幕管线前"的最后一个矩阵步骤。
常见 WebGL 代码写法
js
const fov = 60 * Math.PI / 180;
const aspect = canvas.clientWidth / canvas.clientHeight;
const near = 0.1;
const far = 1000;
const projectionMatrix = perspective(fov, aspect, near, far);
gl.uniformMatrix4fv(uProjectionMatrixLocation, false, projectionMatrix);
顶点着色器:
glsl
attribute vec3 aPosition;
uniform mat4 uModelMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;
void main() {
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aPosition, 1.0);
}
为什么有时窗口变化后画面会变形
最常见原因就是:
aspect没更新- 画布尺寸变了,但投影矩阵还是旧的
正确做法是当 canvas 尺寸变化后重新计算:
js
const aspect = canvas.clientWidth / canvas.clientHeight;
projectionMatrix = perspective(fov, aspect, near, far);
否则会出现:
- 圆变椭圆
- 模型横向或纵向拉伸
投影矩阵最常见的错误
错误 1:fov 没转弧度:Math.tan()要吃弧度,不是角度错误 2:aspect 写反:正确通常是width / height错误 3:near <= 0:透视投影会直接异常错误 4:near 太小,far 太大:导致深度精度差,画面闪烁错误 5:窗口 resize 后没重算投影矩阵:导致画面变形错误 6:把投影矩阵和视图矩阵职责混淆:投影矩阵不负责相机位置错误 7:行主序和列主序理解混乱:数组没问题但显示结果错乱
怎么验证投影矩阵是否正确
你可以这样逐步排查:
- 固定
modelMatrix = I - 固定
viewMatrix,让相机看向原点 - 只测试一个简单立方体或三角形
- 修改
fov,确认视野大小明显变化 - 修改
near/far,确认物体进入或离开可见范围时会被裁剪 - 修改
aspect,确认宽高比匹配后不再拉伸
和 three.js 对照理解
如果你熟悉 three.js,可以这样映射:
PerspectiveCamera内部会生成透视投影矩阵OrthographicCamera内部会生成正交投影矩阵- 它们最终都会维护一个
camera.projectionMatrix
本质和原生 WebGL 一样,只是库帮你把细节封装了。
一句话总结
- 创建投影矩阵,本质是在定义"摄像机镜头规则"和"可见空间范围"。
- 最常见的透视投影写法是:
js
projectionMatrix = perspective(fov, aspect, near, far);
- 如果不需要近大远小,则用:
js
projectionMatrix = orthographic(left, right, bottom, top, near, far);