矩阵专题3-怎么创建投影矩阵(uProjectionMatrix)

核心作用

  • 投影矩阵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

这一步叫透视除法

得到的 NDCNormalized Device Coordinates,通常范围是:

text 复制代码
x: [-1, 1]
y: [-1, 1]
z: [-1, 1]   // WebGL / OpenGL 体系

之后再经过视口变换,才会真正映射到屏幕像素。

所以投影矩阵不是直接把点变成屏幕坐标,而是先把点送入一个标准化流程,后续由GPU自动完成。

为什么透视投影会"近大远小"

这是理解投影矩阵的关键。

假设一个点离摄像机越远,它在屏幕上的投影应该越小。 这意味着:

  • xy 不能只是简单线性变换
  • 它们要和深度 z 产生关系

透视投影的核心直觉就是:

text 复制代码
x' ≈ x / z
y' ≈ y / z

也就是:

  • 越远,除得越多,看起来越小
  • 越近,除得越少,看起来越大

矩阵本身先把这个"除以 z"的效果编码进 w 然后交给后面的透视除法完成。

所以你可以把透视投影矩阵理解成:

  • 先准备好一个特殊的 w
  • 再通过 x/wy/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°
  • 第一人称有时更大
  • 产品展示可能更克制

如果 nearfar 设得不好,会怎样

这部分非常重要,很多初学者会忽略。

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

正交投影通常由这几个参数决定:

  • left
  • right
  • bottom
  • top
  • near
  • far

意思是定义一个长方体可见范围。

正交投影矩阵的常见形式

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);
相关推荐
泯泷3 小时前
第 2 篇:设计第一套字节码:Opcode、Instruction 与 Constant Pool
前端·javascript·安全
泯泷3 小时前
第 1 篇:从 1 + 2 开始:亲手写出第一台 JSVM
前端·javascript·安全
朦胧之4 小时前
页面白屏卡住排查方法
前端·javascript
犇驫聊AI4 小时前
Chrome DevTools MCP + Claude Code 自定义skills生成接口代码生成器
前端·javascript
kyriewen5 小时前
别再这样写 async/await 了:我在 Code Review 中见过最多的 8 个错误
前端·javascript·面试
用户298698530149 小时前
在 React 中使用 JavaScript 将 Excel 转换为 SVG
前端·javascript·react.js
labixiong10 小时前
手写Promise--微任务、静态方法、async/await 全搞懂(三)
前端·javascript
铁皮饭盒11 小时前
3行代码搞定页面截图,Bun.WebView真的简单
javascript
kyriewen1 天前
我手写了一个 EventEmitter,面试官追问了 6 个问题——第 4 个我没答上来
前端·javascript·面试