核心定义
视图矩阵,View Matrix,作用是把顶点从世界坐标系变换到摄像机坐标系。- 它回答的问题不是"物体在哪",而是"从摄像机视角看,这个世界是什么样子"。
- 在 WebGL 顶点着色器里,常见写法是:
glsl
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aPosition, 1.0);
- 这里的
uViewMatrix,就是视图矩阵。
先建立最重要的直觉
- 模型矩阵是"把物体放进世界"。
- 视图矩阵是"把世界转换到摄像机眼里"。
- 投影矩阵是"把摄像机看到的内容压到屏幕规则里"。
所以整个链路是:
text
局部坐标 -> 世界坐标 -> 摄像机坐标 -> 裁剪坐标
对应就是:
text
local -> model -> world -> view -> camera -> projection
更标准一点写:
text
worldPosition = modelMatrix * localPosition
cameraPosition = viewMatrix * worldPosition
clipPosition = projectionMatrix * cameraPosition
视图矩阵为什么看起来有点"反直觉"
初学者最容易困惑的是:
- 明明是"摄像机往前走"
- 为什么代码里经常像是"整个世界往后退"
这是因为在图形学里,通常不是直接"移动摄像机去看世界",而是:
- 固定摄像机在原点
- 把整个世界反过来变换到摄像机坐标系里
所以你可以把视图矩阵理解成:
摄像机变换的逆矩阵
这句话非常关键。
一句话记住视图矩阵
模型矩阵:让物体动视图矩阵:让世界相对摄像机动视图矩阵 = 摄像机世界矩阵的逆
什么叫"摄像机坐标系"
摄像机坐标系可以理解成一个新的局部空间:
- 摄像机自己站在原点
- 摄像机前方是它的"看向方向"
- 摄像机右边是它的"右方向"
- 摄像机上方是它的"上方向"
在这个空间里:
- 摄像机位置永远是
(0, 0, 0) - 所有物体的位置,都是"相对于摄像机"来描述的
比如:
- 世界中某物体在
(10, 0, 0) - 摄像机在
(8, 0, 0)
那么从摄像机角度看,这个物体其实在:
text
(2, 0, 0)
这就是视图矩阵在做的事。
最简单的情况:只有平移,没有旋转
假设摄像机在世界坐标中位置是:
text
camera = (cx, cy, cz)
如果摄像机没有旋转,只是平移,那么视图矩阵可以先粗略理解成:
text
V = T(-cx, -cy, -cz)
也就是说:
- 摄像机往右移动 5
- 等价于整个世界往左移动 5
比如摄像机在 (0, 0, 5),看向原点:
text
camera = (0, 0, 5)
那么视图矩阵大致相当于:
text
V = T(0, 0, -5)
意思是:
- 把整个世界沿 z 轴往后推 5 个单位
- 这样摄像机就像待在原点看场景
这个例子能帮你建立第一层直觉。
但真实情况不止平移,还要有朝向
摄像机不仅有位置,还有朝向:
- 它站在哪里
- 它看向哪里
- 它头顶朝哪边
所以创建视图矩阵,通常要知道 3 个信息:
eye:摄像机位置target:摄像机看向的目标点up:摄像机的上方向
这就是最常见的 lookAt 思想。
创建视图矩阵最常见的方法:lookAt
在 WebGL 里,最经典的创建视图矩阵方式就是:
text
viewMatrix = lookAt(eye, target, up)
比如:
js
eye = [0, 2, 5]
target = [0, 0, 0]
up = [0, 1, 0]
意思是:
- 摄像机站在
(0, 2, 5) - 看向世界原点
(0, 0, 0) - 头顶方向是 y 轴正方向
这个 lookAt 本质上就是帮你构造一个视图矩阵。
lookAt 到底怎么计算
这部分是"创建视图矩阵"的核心。
要构造摄像机坐标系,需要先得到摄像机的 3 个基向量:
z轴方向:摄像机朝向的前后方向x轴方向:摄像机的右方向y轴方向:摄像机的上方向
但不同教材对"前方向"定义略有差别。为了贴近 WebGL / OpenGL 传统,我们常这么做:
第一步:求前方向
设:
text
eye = 摄像机位置
target = 目标点
摄像机看向目标的方向是:
text
forward = normalize(target - eye)
但在很多视图矩阵推导里,使用的是摄像机局部 z 轴的反方向,所以常写成:
text
zAxis = normalize(eye - target)
这表示:
zAxis指向"从目标回到摄像机"- 也可以理解成摄像机的"后方向"
这个写法在 OpenGL 风格的 lookAt 里很常见。
第二步:求右方向
有了上方向 up 和 zAxis,就可以求右方向:
text
xAxis = normalize(cross(up, zAxis))
这里的叉乘 cross 很重要。
它的意义是:
- 用两个方向向量,求出一个垂直于它们的方向
- 得到摄像机坐标系的右方向
第三步:重新求真正的上方向
因为用户传进来的 up 不一定与视线完全垂直,所以通常要重新算一个真正正交的上方向:
text
yAxis = cross(zAxis, xAxis)
这样就得到一组互相垂直的坐标轴:
xAxis:右yAxis:上zAxis:后
这三个方向,构成了摄像机自己的局部坐标系。
为什么一定要这三个轴互相垂直
因为矩阵本质上不只是"存数字",它还在描述一个新坐标系:
- x 轴朝哪
- y 轴朝哪
- z 轴朝哪
- 原点在哪
视图矩阵就是在构造"摄像机坐标系"。
如果 3 个轴不正交,就会出现:
- 拉伸
- 变形
- 视角不稳定
所以 lookAt 的本质其实就是:
- 根据
eye、target、up - 构造一个规范的摄像机坐标系
- 然后把世界映射到这个坐标系中
视图矩阵的结构长什么样
在列向量、OpenGL 风格下,视图矩阵通常可写成:
text
| x.x x.y x.z -dot(x, eye) |
| y.x y.y y.z -dot(y, eye) |
| z.x z.y z.z -dot(z, eye) |
| 0 0 0 1 |
或者在不同教材里看到转置版本,本质是一回事,只是:
- 行向量还是列向量
- 行主序还是列主序
- 数组布局方式不同
你需要抓住的是本质:
- 前 3 列或前 3 行,是摄像机坐标轴
- 最后一列或最后一行,跟摄像机位置有关
- 本质是"旋转 + 逆平移"
为什么最后会有 -dot(axis, eye)
这是很多人第一次看 lookAt 时最疑惑的部分。
直觉上你可以这样理解:
- 视图矩阵不是单纯记录摄像机位置
- 它要把"世界中的点"换算到"摄像机坐标系"
- 所以平移不再是简单写
-eye - 而是要先考虑摄像机已经旋转后的局部轴方向
所以最后那部分相当于:
- 把摄像机位置投影到它自己的右、上、后轴上
- 再取负号
- 作为世界变换到摄像机空间时的偏移量
这就是 -dot(axis, eye) 的来源。
从"摄像机矩阵求逆"角度理解更容易
另一种非常清晰的理解方式是:
先假设有一个"摄像机世界矩阵":
- 它描述摄像机在世界里站在哪
- 它描述摄像机朝向哪里
这个矩阵可以理解成:
text
cameraWorldMatrix = T * R
也就是:
- 先旋转出摄像机朝向
- 再平移到摄像机位置
但渲染时我们需要的不是这个,而是:
text
viewMatrix = inverse(cameraWorldMatrix)
因为我们不是要把摄像机放到世界里,而是要把世界转到摄像机里。
这个角度非常适合工程实现。
所以创建视图矩阵,本质有两种思路
思路 1:直接用 lookAt
- 给出
eye - 给出
target - 给出
up - 直接构造视图矩阵
这是最常见、最直观的方法。
思路 2:先构造摄像机世界矩阵,再求逆
- 先有摄像机的
position - 再有摄像机的
rotation - 生成
cameraWorldMatrix - 再做逆矩阵得到
viewMatrix
这是引擎里更常见的方法。
WebGL 中手写 lookAt 的核心步骤
下面用接近原理的方式写一个简化版:
js
function normalize(v) {
const len = Math.hypot(v[0], v[1], v[2]);
return len > 0.00001
? [v[0] / len, v[1] / len, v[2] / len]
: [0, 0, 0];
}
function subtract(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
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],
];
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function lookAt(eye, target, up) {
const zAxis = normalize(subtract(eye, target));
const xAxis = normalize(cross(up, zAxis));
const yAxis = cross(zAxis, xAxis);
return new Float32Array([
xAxis[0], yAxis[0], zAxis[0], 0,
xAxis[1], yAxis[1], zAxis[1], 0,
xAxis[2], yAxis[2], zAxis[2], 0,
-dot(xAxis, eye), -dot(yAxis, eye), -dot(zAxis, eye), 1,
]);
}
这个写法是为了帮助你理解:
- 先算摄像机的三个局部轴
- 再算平移部分
- 最后组成矩阵
注意:
- 不同库的数组顺序可能不同
- 但数学含义是一样的
传给 WebGL 的时候怎么用
你创建好视图矩阵后,传给着色器:
js
gl.uniformMatrix4fv(uViewMatrixLocation, false, viewMatrix);
顶点着色器中:
glsl
attribute vec3 aPosition;
uniform mat4 uModelMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;
void main() {
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aPosition, 1.0);
}
这时顶点会按下面顺序变换:
uModelMatrix:局部 -> 世界uViewMatrix:世界 -> 摄像机uProjectionMatrix:摄像机 -> 裁剪空间
结合一个具体例子理解
假设:
js
eye = [0, 0, 5];
target = [0, 0, 0];
up = [0, 1, 0];
含义:
- 摄像机站在 z 轴正方向 5 的位置
- 向原点看
- 头顶朝上
那么从摄像机角度看:
- 原点就在它正前方
- 世界中的点都会被重新换算成"相对于摄像机"的位置
比如原点 (0, 0, 0) 会变成大概:
text
(0, 0, -5)
这就符合图形学里常见约定:
- 摄像机看向负 z 方向
- 在摄像机前方的物体,z 通常是负值
视图矩阵和模型矩阵最容易混淆的点
很多人刚学时会把这两个搞混。
模型矩阵
- 作用对象:单个物体
- 作用:把物体从局部坐标放到世界里
- 关心:物体的位置、旋转、缩放
视图矩阵
- 作用对象:整个世界
- 作用:把世界转换到摄像机坐标系
- 关心:摄像机的位置和朝向
所以:
- 模型矩阵是"物体怎么摆"
- 视图矩阵是"摄像机怎么看"
为什么视图矩阵本质是逆矩阵
这个一定要真正理解。
假设摄像机在世界里:
- 向右移动了 10
- 向上移动了 2
- 绕 y 轴旋转了 30 度
这是摄像机的"世界变换"。
但为了把世界转换到摄像机空间,你必须做反操作:
- 世界向左移动 10
- 世界向下移动 2
- 世界绕 y 轴反向旋转 30 度
这就是逆矩阵的含义。
所以:
text
viewMatrix = inverse(cameraWorldMatrix)
它不是一个技巧,而是本质。
和 three.js 的关系
如果你熟悉 three.js,可以这样对照理解:
camera.matrixWorld:摄像机在世界中的矩阵camera.matrixWorldInverse:视图矩阵camera.projectionMatrix:投影矩阵
也就是:
text
viewMatrix = camera.matrixWorldInverse
所以 three.js 并没有跳出 WebGL 这套逻辑,它只是帮你把矩阵都管理好了。
创建视图矩阵时最常见的错误
错误 1:把 eye - target 和 target - eye 写反
- 会导致摄像机朝向反了
- 场景可能完全颠倒
错误 2:up 向量不合理
- 如果
up和视线方向平行或几乎平行 - 叉乘会失效
- 相机会抖动或矩阵异常
比如:
js
eye = [0, 1, 0]
target = [0, 0, 0]
up = [0, -1, 0]
这种就很危险。
错误 3:把视图矩阵当模型矩阵用
- 结果就是相机和物体逻辑混乱
- 很容易"看起来能动,但全都不对"
错误 4:行主序列主序混淆
- 数学推导没错
- 但数组传给 WebGL 后结果错位
- 这是最常见的"明明公式对,画面却不对"
错误 5:忘了视图矩阵是逆矩阵
- 自己手动拼
T * R - 却直接当
viewMatrix用 - 最终视角方向就会不对
怎么验证自己创建的视图矩阵是对的
你可以按这个顺序调试:
第一步:固定模型矩阵为单位矩阵
text
modelMatrix = I
第二步:固定投影矩阵正常
确保透视矩阵没问题。
第三步:只改变摄像机位置,不旋转
比如:
js
eye = [0, 0, 5]
target = [0, 0, 0]
如果原点物体正确出现在视野中心,说明基础平移逻辑没问题。
第四步:改变 target
比如让相机看向左边:
js
eye = [0, 0, 5]
target = [-1, 0, 0]
看场景是否跟着改变朝向。
第五步:打印三个轴向量
确认:
xAxisyAxiszAxis
是否单位长度、互相垂直。
视图矩阵可以拆成哪两部分
从结构上看,它本质上可以理解为:
text
View = RotationInverse * TranslationInverse
也可以写成"先把世界平移到摄像机原点,再按摄像机反方向旋转"。
这和模型矩阵正好相反。
模型矩阵常写:
text
Model = Translation * Rotation * Scale
视图矩阵则更像:
text
View = inverse(Translation * Rotation)
或者:
text
View = inverse(CameraWorldMatrix)
最适合记忆的理解方式
你可以把视图矩阵记成下面这句话:
视图矩阵不是在移动摄像机,而是在把整个世界变换到以摄像机为原点的坐标系里。
这句话一旦理解透,很多问题都会通:
- 为什么要取逆
- 为什么摄像机往右,世界反而往左
- 为什么
lookAt能创建视图矩阵 - 为什么
viewMatrix只和相机有关,不和具体模型有关
一句话总结
- 创建视图矩阵,本质就是创建"摄像机坐标系",然后把世界坐标转换到这个坐标系中。
- 最常见的方法是:
text
viewMatrix = lookAt(eye, target, up)
- 更底层的本质是:
text
viewMatrix = inverse(cameraWorldMatrix)