3.2 视图矩阵
上一节把物体搬进了世界坐标系,这一节把相机也写成矩阵:视图矩阵把世界坐标搬进相机自己的坐标系。在 OpenGL 里,相机无非是一组 uniform 与一次矩阵上传,lookAt 的语义由 GL 数学库约定;如果你要找的是 GL 核心模式下这一级变换挂在管线的哪一段、状态如何设置,请看 OpenGL 核心模式渲染管线
。本节把同一件事落到整数上:lookAt 的三个正交基怎么算、平移项从哪来、为什么它恰好是相机变换的逆,以及退化情形下会发生什么。
视图矩阵是相机变换的逆
相机在世界里有一个位置 eye 和一个朝向。把相机自身当作一个物体,它的世界变换是 C = [R | eye]:R 是相机的旋转(三行分别是右向 s、上向 u、前向取反 -f),eye 是平移。视图矩阵要做的事正相反——把世界里的点表示成「相对相机」的坐标,所以它就是 C 的逆:V = C⁻¹ = [Rᵀ | -Rᵀ·eye]。
因为 R 是正交阵(三行单位正交),Rᵀ = R⁻¹,逆矩阵写起来很便宜:转置即可。视图矩阵的 3x3 部分就是 Rᵀ,平移列是 -Rᵀ·eye。这也解释了它为什么叫「相机的逆变换」——同一个 R,正着用是把相机摆到世界里,反着用是把世界搬进相机。
lookAt 的三个正交基
给定 eye、target、up 三个世界向量,lookAt 先构造一组正交基:
f = norm(target - eye) 前向:相机看向哪里
s = norm(f × up) 右向:前向与 up 的叉积
u = s × f 上向:右向与前向的叉积(已自动正交化)
注意第三步没有再做归一化:s 与 f 已经单位正交,s × f 自动是单位向量且与二者垂直。这样得到的 f/s/u 里,up 只提供「大致的上方」,真正的上向由 u = s × f 重新给出——这正是 lookAt 对「相机不能翻滚」的表达。三个基向量按右手系排布,相机看向 -z,于是视图空间里前向对应 -f。
传进来的 up 甚至不要求与视线严格垂直。即便它有点歪,s = norm(f × up) 与 u = s × f 这两步也会把它重新正交化:先投影到与 f 垂直的平面上,再重新定出 u。换句话说,up 只负责回答「哪边算上」,至于「上」到底多精确,由后续的叉积保证。三个基满足右手关系 s × u = -f,相机看向视图空间的 -z——这个负号约定与下一节投影矩阵里近远平面都落在负半空间相互咬合。
平移项从哪来
视图矩阵的四行分别是 [s, -s·eye]、[u, -u·eye]、[-f, f·eye]、[0, 0, 0, 1]。平移列的每一项都是「把基向量与相机位置做点积再取负」,来源就是 -Rᵀ·eye:Rᵀ 的第 i 行是 R 的第 i 列,展开后正好是 s·eye、u·eye、f·eye 三组点积。所以视图矩阵不需要额外的三角函数,只要 norm、cross、dot 三个定点运算。
验证:世界原点在视图空间的位置
脚本从场景规格里读出相机,构造正交基与视图矩阵,然后验证两件事:相机自己落在视图空间原点;世界原点在视图空间的位置等于「相机位置取反,再用视图的旋转部分变换」。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_03_2_view_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_03_2_view_matrix.py
"""3.2 视图矩阵:lookAt 的正交基 f/s/u、平移项推导、退化情形。
全整数;复用 sim/cglib.py 的 v_norm / v_cross / v_dot / v_len / mat_vec4 / mat_look_at。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ONE = cglib.ONE
def basis(eye, target, up):
"""返回 (f, s, u):相机前向、右向、上向的正交基(Q16.16)。"""
f = cglib.v_norm(cglib.v_sub(target, eye))
s = cglib.v_norm(cglib.v_cross(f, up))
u = cglib.v_cross(s, f)
return f, s, u
def main():
spec = cglib.load_spec()
cam = spec["camera"]
eye = tuple(cam["eyeQ16"])
target = tuple(cam["targetQ16"])
up = tuple(cam["upQ16"])
f, s, u = basis(eye, target, up)
view = cglib.mat_look_at(eye, target, up)
# 相机自身与世界的原点,分别在视图空间里的位置
cam_in_view = cglib.mat_vec4(view, (eye[0], eye[1], eye[2], ONE))
origin_in_view = cglib.mat_vec4(view, (0, 0, 0, ONE))
neg_eye = (-eye[0], -eye[1], -eye[2])
# A = [s; u; -f] 是视图矩阵的旋转部分:A·(-eye) 应等于世界原点的视图坐标
a_neg_eye = (
cglib.v_dot(s, neg_eye),
cglib.v_dot(u, neg_eye),
cglib.v_dot((-f[0], -f[1], -f[2]), neg_eye),
)
# 退化:视线与 up 平行 -> f×up 为零向量 -> 正交基塌缩
f_bad, s_bad, u_bad = basis((0, 0, 5 * ONE), (0, 0, 0), (0, 0, ONE))
view_bad = cglib.mat_look_at((0, 0, 5 * ONE), (0, 0, 0), (0, 0, ONE))
def f3(v):
return "(" + ", ".join(f"{x / ONE:+.3f}" for x in v[:3]) + ")"
print(f"f = norm(t-e) : {f3(f)}")
print(f"s = norm(f×up) : {f3(s)}")
print(f"u = s×f : {f3(u)}")
print(f"s·u / s·f / u·f: {cglib.v_dot(s, u)} / {cglib.v_dot(s, f)} / {cglib.v_dot(u, f)}")
print(f"|s| / |u| / |f|: {cglib.v_len(s)} / {cglib.v_len(u)} / {cglib.v_len(f)} (ONE={ONE})")
print(f"view·eye : {f3(cam_in_view)} (相机落在视图原点)")
print(f"view·origin : {f3(origin_in_view)}")
print(f"A·(-eye) : {f3(a_neg_eye)}")
print(f"|view·origin| : {cglib.v_len(origin_in_view[:3])} vs |eye| = {cglib.v_len(eye)}")
print(f"degenerate f : {f3(f_bad)}")
print(f"degenerate s,u : {f3(s_bad)} / {f3(u_bad)} (零向量)")
print(f"degenerate row0: {list(view_bad[:4])}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出:
f = norm(t-e) : (+0.000, -0.164, -0.986)
s = norm(f×up) : (+1.000, +0.000, +0.000)
u = s×f : (+0.000, +0.986, -0.164)
s·u / s·f / u·f: 0 / 0 / 0
|s| / |u| / |f|: 65536 / 65536 / 65535 (ONE=65536)
view·eye : (+0.000, +0.000, +0.000) (相机落在视图原点)
view·origin : (+0.000, -1.973, -24.660)
A·(-eye) : (+0.000, -1.973, -24.660)
|view·origin| : 1621261 vs |eye| = 1621271
degenerate f : (+0.000, +0.000, -1.000)
degenerate s,u : (+0.000, +0.000, +0.000) / (+0.000, +0.000, +0.000) (零向量)
degenerate row0: [0, 0, 0, 0]
三点读数。第一,view·eye 精确是 (0, 0, 0)——相机落在视图空间原点,这是视图矩阵定义式 [Rᵀ | -Rᵀ·eye] 的直接推论。第二,view·origin 与 A·(-eye) 逐项相同(都是 (0, -1.973, -24.660)),这就把「世界原点在视图空间的位置 = 相机位置取反再旋转」落成了可断言的等式。第三,|view·origin| = 1621261,而 |eye| = 1621271,二者差 10 个 Q16.16 单位(约 1.5×10⁻⁴)——因为 s/u/f 用向零截断的定点除法归一化,A 并非严格正交,旋转会带来一点模长残差。这个残差量级远小于像素,不影响成像,但如实报出来,因为本卷的判据是「逐字节相同」,任何残差都必须可解释。
这三条断言合起来回答了本节标题的问题:视图矩阵不是「让相机移动」的矩阵,而是「把世界相对相机重述」的矩阵。它把相机挪到原点、把相机的朝向摆正到坐标轴上,代价是整个世界跟着做了一次反向的刚体变换。刚体变换保距,所以 |view·origin| 必须约等于 |eye|;残差只来自定点归一化的截断,不来自算法本身。
退化情形:视线与 up 平行
lookAt 的第二步是 s = norm(f × up)。当视线方向与 up 平行时,叉积为零向量,norm 返回零向量,s 与随后的 u 一起塌缩——视图矩阵前两行全零,矩阵奇异。脚本用 eye = (0, 0, 5)、target = (0, 0, 0)、up = (0, 0, 1) 构造了这种输入,输出里 degenerate s,u 两项都是 (0, 0, 0),degenerate row0 是 [0, 0, 0, 0]。工程上的处理是:在调用 lookAt 前检查 |f × up| 是否过小,若过小则换一个与 f 不共线的 up(例如改用 (0, 1, 0) 或 (1, 0, 0));绝不能让一个零行流进后续的矩阵链,否则整帧几何会被压到原点。这里不静默修正,而是把退化输入暴露成可见的零行——可证伪比「看起来能跑」更重要。
P 层:跨语言逐项一致
TypeScript 侧用 matLookAt 复现同一批整数,并断言三条不变量:视图矩阵逐项等于 S 侧金标准、view·eye == 0、view·origin 等于 A·(-eye)。退化情形也一并断言。
// file: lantern/core/test/cg_raster_03_2_view_matrix.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_03_2_view_matrix.test.ts'
// 3.2 视图矩阵的 P 层:与 S 层 sim/cg_raster_03_2_view_matrix.py 逐项一致。
//
// 金标准整数由 S 侧独立产出:
// /opt/homebrew/bin/python3 sim/cg_raster_03_2_view_matrix.py
import assert from "node:assert/strict";
import { test } from "node:test";
import { ONE } from "../src/lantern/fixed.ts";
import { matLookAt, matVec4, type Mat4 } from "../src/lantern/mat.ts";
import { type Vec3, vCross, vDot, vLen, vNorm, vSub } from "../src/lantern/vec.ts";
// 与 config/scene_spec.json 的相机一致
const EYE: Vec3 = [0n, 393216n, 1572864n];
const TARGET: Vec3 = [0n, 131072n, 0n];
const UP: Vec3 = [0n, 65536n, 0n];
const GOLDEN_VIEW: Mat4 = [
65537n, 0n, 0n, 0n,
0n, 64645n, -10775n, -129270n,
0n, 10774n, 64644n, -1616100n,
0n, 0n, 0n, 65536n,
];
const GOLDEN_ORIGIN_IN_VIEW = [0n, -129270n, -1616100n, 65536n];
test("lookAt 构造的视图矩阵与 S 层逐项相同", () => {
assert.deepEqual(matLookAt(EYE, TARGET, UP), GOLDEN_VIEW);
});
test("相机自身落在视图空间原点:view·eye == 0", () => {
const view = matLookAt(EYE, TARGET, UP);
assert.deepEqual(matVec4(view, [EYE[0], EYE[1], EYE[2], ONE]), [0n, 0n, 0n, 65536n]);
});
test("世界原点在视图空间的位置 = 相机位置取反再旋转", () => {
const view = matLookAt(EYE, TARGET, UP);
const originInView = matVec4(view, [0n, 0n, 0n, ONE]);
assert.deepEqual(originInView, GOLDEN_ORIGIN_IN_VIEW);
// A = [s; u; -f] 是视图矩阵的旋转部分;A·(-eye) 必须等于世界原点的视图坐标
const f = vNorm(vSub(TARGET, EYE));
const s = vNorm(vCross(f, UP));
const u = vCross(s, f);
const negEye: Vec3 = [-EYE[0], -EYE[1], -EYE[2]];
assert.deepEqual(
[vDot(s, negEye), vDot(u, negEye), vDot([-f[0], -f[1], -f[2]], negEye), ONE],
originInView,
);
});
test("正交基:s·u == s·f == u·f == 0,且 |s| == |u| == ONE", () => {
const f = vNorm(vSub(TARGET, EYE));
const s = vNorm(vCross(f, UP));
const u = vCross(s, f);
assert.equal(vDot(s, u), 0n);
assert.equal(vDot(s, f), 0n);
assert.equal(vDot(u, f), 0n);
assert.equal(vLen(s), ONE);
assert.equal(vLen(u), ONE);
assert.equal(s[0], 65537n); // 定点归一化后的实测值(Q16.16)
});
test("退化:视线与 up 平行 -> 叉积为零向量,视图矩阵前两行归零", () => {
const view = matLookAt([0n, 0n, 5n * ONE], [0n, 0n, 0n], [0n, 0n, ONE]);
assert.deepEqual([view[0], view[1], view[2], view[3]], [0n, 0n, 0n, 0n]);
assert.deepEqual([view[4], view[5], view[6], view[7]], [0n, 0n, 0n, 0n]);
});
$ node --test 'core/test/cg_raster_03_2_view_matrix.test.ts'
✔ lookAt 构造的视图矩阵与 S 层逐项相同
✔ 相机自身落在视图空间原点:view·eye == 0
✔ 世界原点在视图空间的位置 = 相机位置取反再旋转
✔ 正交基:s·u == s·f == u·f == 0,且 |s| == |u| == ONE
✔ 退化:视线与 up 平行 -> 叉积为零向量,视图矩阵前两行归零
ℹ tests 5 pass 5 fail 0
与模型矩阵的关系:V·M 可以预合并
模型矩阵 M 与视图矩阵 V 都是仿射变换,最后一行为 (0, 0, 0, 1)。把二者相乘得到 V·M,作用等价于「先做模型变换、再做视图变换」,但每顶点只需一次矩阵乘向量。对静态物体、静态相机,V·M 可以在初始化时算好并缓存;对逐帧移动的物体,则保留 M 单独更新,避免每次重算。本卷的主管线把 M 与 V 分开保留,是因为 config/scene_spec.json 里的几何已经烘焙到世界坐标,M 恰好是单位阵——分开写更便于逐段核对每一步的定点结果,也让「模型矩阵」「视图矩阵」「投影矩阵」三节各自有独立的可断言对象。
小结
视图矩阵是相机世界变换的逆:3x3 部分是相机旋转的转置,平移列是 -Rᵀ·eye。lookAt 用 f = norm(target - eye)、s = norm(f × up)、u = s × f 三个式子构造正交基,平移项由基向量与 eye 的点积给出。可断言的等式是「相机落在视图原点」与「世界原点在视图空间的位置 = 相机位置取反再旋转」。退化输入(视线与 up 平行)会让叉积塌缩为零向量,必须在上游拦截。下一节把投影矩阵补上,拼成完整的 MVP = P·V·M,并跑出与 Python 侧逐字节相同的一帧。
阅读导航:上一节:3.1 模型矩阵 · 下一节:3.3 投影矩阵与 MVP 合成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。