矩阵专题2-怎么创建视图矩阵(uViewMatrix)

核心定义

  • 视图矩阵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 里很常见。


第二步:求右方向

有了上方向 upzAxis,就可以求右方向:

text 复制代码
xAxis = normalize(cross(up, zAxis))

这里的叉乘 cross 很重要。

它的意义是:

  • 用两个方向向量,求出一个垂直于它们的方向
  • 得到摄像机坐标系的右方向

第三步:重新求真正的上方向

因为用户传进来的 up 不一定与视线完全垂直,所以通常要重新算一个真正正交的上方向:

text 复制代码
yAxis = cross(zAxis, xAxis)

这样就得到一组互相垂直的坐标轴:

  • xAxis:右
  • yAxis:上
  • zAxis:后

这三个方向,构成了摄像机自己的局部坐标系。


为什么一定要这三个轴互相垂直

因为矩阵本质上不只是"存数字",它还在描述一个新坐标系:

  • x 轴朝哪
  • y 轴朝哪
  • z 轴朝哪
  • 原点在哪

视图矩阵就是在构造"摄像机坐标系"。

如果 3 个轴不正交,就会出现:

  • 拉伸
  • 变形
  • 视角不稳定

所以 lookAt 的本质其实就是:

  • 根据 eyetargetup
  • 构造一个规范的摄像机坐标系
  • 然后把世界映射到这个坐标系中

视图矩阵的结构长什么样

在列向量、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 - targettarget - 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]

看场景是否跟着改变朝向。

第五步:打印三个轴向量

确认:

  • xAxis
  • yAxis
  • zAxis

是否单位长度、互相垂直。

视图矩阵可以拆成哪两部分

从结构上看,它本质上可以理解为:

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)
相关推荐
tangdou3690986552 小时前
AI真好玩系列-2分钟快速了解DeepAgents | Quick Guide to DeepAgents in 2 Minutes
前端·javascript·后端
张元清2 小时前
React useIntersectionObserver Hook:懒加载与可见性检测(2026)
javascript·react.js
彭于晏爱编程3 小时前
纯 JS + Node,一个下午手搓了能读懂公司代码的 AI 助手,老板以为我转行了
前端·javascript
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十四):眨眼小人登录页制作
前端·javascript·ai编程
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十三):前端路由初始化
前端·javascript·ai编程
PBitW4 小时前
GPT训练我的第四天,被打惨了!!!😭😭😭
前端·javascript·面试
DarkLONGLOVE4 小时前
快速上手 Pinia!Vue3 极简状态管理使用教程
javascript·vue.js
mackbob4 小时前
.eslintrc.js详细配置说明
javascript
用户298698530146 小时前
在 React 中使用 JavaScript 将 Excel 转换为 PDF
javascript·react.js·webassembly