JavaScript 中的矩阵变换:手写 lookAt 与透视投影(Perspective Projection)矩阵
大家好,今天我们来深入探讨计算机图形学中两个非常核心的变换操作:视角定位(Look At) 和 透视投影(Perspective Projection)。这两个变换是构建3D场景渲染管线的基础环节,尤其在使用 WebGL、Three.js 或者自定义渲染引擎时必不可少。
我们将从零开始,用纯 JavaScript 实现这两个矩阵的计算逻辑,并通过代码演示它们如何组合起来将世界坐标系中的点映射到屏幕空间。整个过程不依赖任何第三方库(除了必要的数学辅助函数),确保你真正理解底层原理。
一、为什么要自己实现这些矩阵?
虽然像 Three.js 这样的框架已经封装好了 lookAt 和 perspective 方法,但如果你想要:
- 更好地理解摄像机是如何工作的;
- 自定义渲染流程(比如做游戏引擎、可视化工具);
- 在没有高级 API 的环境中(如原生 WebGL)进行开发;
那么亲手写一遍这些矩阵是非常值得的投资。这不仅能提升你的线性代数能力,还能让你在调试问题时更加自信——你知道每一行代码背后代表的是什么几何意义。
二、基础概念回顾:什么是矩阵变换?
2.1 矩阵的基本作用
在3D图形中,一个点通常表示为齐次坐标 (x, y, z, 1),而变换(平移、旋转、缩放等)可以通过乘以一个 4×4 矩阵完成:
[ x' ] [ M00 M01 M02 M03 ] [ x ]
[ y' ] = [ M10 M11 M12 M13 ] [ y ]
[ z' ] [ M20 M21 M22 M23 ] [ z ]
[ w' ] [ M30 M31 M32 M33 ] [ 1 ]
其中,最终结果要除以 w' 得到归一化后的坐标(即 x'/w', y'/w', z'/w'),这就是齐次坐标的本质。
2.2 变换类型简述
| 类型 | 功能 |
|---|---|
| 平移矩阵 | 移动物体位置 |
| 旋转矩阵 | 绕轴旋转物体 |
| 缩放矩阵 | 改变物体大小 |
| 投影矩阵 | 将3D世界投射到2D屏幕 |
我们今天关注的就是后两种:视图变换(View Transform) 和 投影变换(Projection Transform)。
三、第一步:实现 lookAt 矩阵 —— 定义相机视角
3.1 什么是 lookAt?
lookAt(eye, target, up) 是一种常见的摄像机设置方式,它告诉系统:
- 相机当前位置(eye)
- 目标点(target),相机朝向哪里
- 向上方向(up),用于确定“哪边是上”
输出是一个 视图矩阵(View Matrix),这个矩阵的作用是把世界坐标系下的点转换成相对于相机的坐标系(也就是“从相机角度看世界”)。
3.2 数学推导
我们要构造这样一个矩阵:
让相机位于 eye,看向 target,并保持 up 方向为正上方。
步骤如下:
-
计算相机前向向量:
forward = normalize(target - eye) -
计算右向量(由 up × forward 得到):
right = normalize(cross(up, forward)) -
再次计算新的向上向量(由 forward × right 得到):
upVector = cross(forward, right)
注意:这里为什么需要重新计算 up?因为原始的 up 可能不垂直于 forward,所以必须正交化处理,否则会导致扭曲。
现在我们有了三个正交单位向量:right、up、forward。它们构成了相机自身的局部坐标系。
3.3 构造视图矩阵
视图矩阵本质上是一个旋转 + 平移的组合。我们可以这样写:
function lookAt(eye, target, up) {
const forward = normalize(subtract(target, eye));
const right = normalize(cross(up, forward));
const upVector = cross(forward, right);
// 构建旋转部分(注意:转置等于逆)
const rotation = [
right[0], right[1], right[2], 0,
upVector[0], upVector[1], upVector[2], 0,
forward[0], forward[1], forward[2], 0,
0, 0, 0, 1
];
// 平移部分:将相机移到原点(相当于反向移动世界)
const translation = [
1, 0, 0, -eye[0],
0, 1, 0, -eye[1],
0, 0, 1, -eye[2],
0, 0, 0, 1
];
// 合并:先旋转再平移(注意顺序!)
return multiplyMatrix(rotation, translation);
}
辅助函数说明(需自行实现)
// 向量减法
function subtract(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
// 向量归一化
function normalize(v) {
const len = Math.sqrt(v[0]**2 + v[1]**2 + v[2]**2);
return len > 0 ? [v[0]/len, v[1]/len, v[2]/len] : [0, 0, 0];
}
// 叉积(三维向量)
function cross(a, b) {
return [
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0]
];
}
// 矩阵乘法(4x4 × 4x4)
function multiplyMatrix(a, b) {
const result = new Array(16).fill(0);
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
for (let k = 0; k < 4; k++) {
result[i*4 + j] += a[i*4 + k] * b[k*4 + j];
}
}
}
return result;
}
✅ 这个 lookAt 函数就是标准 OpenGL 风格的视图矩阵生成器!
四、第二步:实现透视投影矩阵(Perspective Projection)
4.1 什么是透视投影?
透视投影模拟人眼观察世界的感受:近大远小。它把一个锥形视景体(frustum)压缩成一个立方体(normalized device coordinates, NDC),然后可以轻松映射到屏幕像素。
关键参数包括:
| 参数 | 描述 |
|---|---|
| fovY | 垂直视角(度数) |
| aspect | 屏幕宽高比(width / height) |
| near | 近裁剪面距离 |
| far | 远裁剪面距离 |
4.2 数学公式推导
根据标准 OpenGL 的透视投影矩阵公式:
[ 1/(aspect*tan(fovY/2)) , 0 , 0 , 0 ]
[ 0 , 1/tan(fovY/2) , 0 , 0 ]
[ 0 , 0 , -(far+near)/(far-near), -1 ]
[ 0 , 0 , -2*far*near/(far-near), 0 ]
这个矩阵的作用是:
- 将 z 轴从
[near, far]映射到[-1, 1](NDC) - 同时保留 x 和 y 的比例关系(基于 FOV 和 aspect)
4.3 JavaScript 实现
function perspective(fovY, aspect, near, far) {
const f = 1 / Math.tan(fovY * Math.PI / 360); // tan(fovY/2)
const nf = near - far;
const n2f = 2 * near * far;
return [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, -(far + near) / nf, -1,
0, 0, -n2f / nf, 0
];
}
📌 提示:这里的 fovY 是角度制,所以要转为弧度再计算 tan。
五、完整示例:从世界坐标 → 屏幕坐标
现在我们有两个矩阵:
- 视图矩阵(来自
lookAt) - 投影矩阵(来自
perspective)
组合起来就是完整的变换链:
screenCoord = projectionMatrix × viewMatrix × worldPoint
让我们写一个完整的例子:
// 示例数据
const eye = [0, 0, 5]; // 相机位置
const target = [0, 0, 0]; // 目标点
const up = [0, 1, 0]; // 上方向
const fovY = 60; // 垂直视野角(度)
const aspect = 16 / 9; // 宽高比
const near = 0.1;
const far = 100;
// 步骤1:生成视图矩阵
const viewMatrix = lookAt(eye, target, up);
// 步骤2:生成投影矩阵
const projMatrix = perspective(fovY, aspect, near, far);
// 步骤3:合成总变换矩阵
const finalMatrix = multiplyMatrix(projMatrix, viewMatrix);
// 测试点:世界空间中的一个点
const worldPoint = [1, 1, 3]; // 在相机前方的一个点
// 应用变换(齐次坐标)
const homogeneous = [worldPoint[0], worldPoint[1], worldPoint[2], 1];
const transformed = multiplyVector(finalMatrix, homogeneous);
// 归一化(除以 w)
const screenX = transformed[0] / transformed[3];
const screenY = transformed[1] / transformed[3];
const screenZ = transformed[2] / transformed[3];
console.log(`Screen coords: (${screenX.toFixed(3)}, ${screenY.toFixed(3)}, ${screenZ.toFixed(3)})`);
📌 输出结果应该是一个在屏幕上的合理坐标(例如:(-0.286, 0.286, -0.75)),说明该点被正确地映射到了 NDC 空间。
六、常见陷阱 & 注意事项
| 问题 | 解决方案 |
|---|---|
| 相机朝向不合理(比如上下颠倒) | 检查 up 向量是否和 forward 不共线,必要时手动调整 |
| 投影后出现奇怪的拉伸或变形 | 确保 aspect 设置正确(窗口宽高比) |
| 深度值异常(z=0 或 z=-1) | 检查 near/far 是否合理(不能小于0,且 far > near) |
| 点不在屏幕上显示 | 使用 gl.viewport() 设置正确的画布区域 |
💡 特别提醒:如果你是在 WebGL 中使用这些矩阵,请记得将它们传递给着色器,并在顶点着色器中执行矩阵乘法。
七、总结:两个矩阵的核心价值
| 矩阵 | 功能 | 对应的几何意义 |
|---|---|---|
lookAt |
视图变换 | 把世界坐标转为相机坐标系(即“从相机看世界”) |
perspective |
投影变换 | 把相机坐标系下的3D点变成2D屏幕坐标(近大远小) |
它们共同构成了现代图形管线的“视口转换”阶段,是所有3D引擎(包括 Unity、Unreal、Three.js)的基础。
八、扩展建议(进阶学习)
如果你想进一步探索:
- 正交投影(Orthographic Projection):适合 UI 或 CAD 场景,没有透视效果。
- 视锥裁剪(Frustum Culling):只渲染可见物体,提升性能。
- 矩阵栈管理(Matrix Stack):支持嵌套变换(如骨骼动画)。
- GPU 加速版本:用 WebAssembly 或 GLSL 编写更高效的矩阵运算。
结语
今天我们不仅实现了两个重要的矩阵变换函数,还理解了它们背后的几何含义。这不是简单的代码堆砌,而是对摄像机工作原理的一次深刻洞察。
记住一句话:
“编程不是复制粘贴,而是理解每一步的意义。”
希望这篇文章能帮助你在图形学道路上走得更远!继续练习吧,未来可能就是你设计下一个 WebGL 游戏引擎的人 😊