3.3 投影矩阵与 MVP 合成
模型矩阵把物体搬进世界,视图矩阵把世界搬进相机,最后一段是投影矩阵:把相机空间的三维视锥压进一个立方体,为后面的透视除法与屏幕映射做好准备。如果你关心的是 OpenGL 把这一步归到管线的哪一级、裁剪空间到 NDC 的除法发生在何处,请看 OpenGL 核心模式渲染管线
;本节关心的是这 16 个数字每一项的来历、它们如何与模型、视图两段拼成 MVP,以及这条链在整数下能否跑出与 Python 侧逐字节相同的一帧。
透视投影矩阵每一项的来历
透视投影矩阵要满足一件事:把视图空间的视锥映射到 NDC 立方体,近平面映到 z = -1、远平面映到 z = +1。设视图空间里一点为 (x, y, z, 1),投影后得到裁剪坐标 (x', y', z', w'),其形式是:
[ r 0 0 0 ] f = 1 / tan(fovY / 2) 纵向缩放
P = [ 0 f 0 0 ] r = f / aspect 横向缩放
[ 0 0 A B ] A = (far + near) / (near - far)
[ 0 0 -1 0 ] B = 2 · far · near / (near - far)
f 与 r 控制视野:f = 1/tan(fovY/2) 把纵向张角折成缩放因子,r = f/aspect 保证方形像素下横纵比例一致。真正决定深度的是第三行。要求「近平面 z = -near 映到 -1、远平面 z = -far 映到 +1」,代入 ndc_z = (A·z + B) / (-z) 得到两个方程,解得 A = (far + near)/(near - far)、B = 2·far·near/(near - far)。第四行的 -1 把 w' = -z,这就是透视除法的来源:离相机越远,w' 越大,除以它之后物体在屏幕上越小。
定点化时两个常数各有一处讲究。A = fdiv(far + near, near - far):分母 near - far 是负数,fdiv 走向零截断,两侧一致。B = fdiv(2 · fmul(far, near), near - far):先 fmul(far, near) 再乘 2,而不是直接算 2·far·near,避免中间量在乘法前就变大;fmul 内部已经做过一次 >> 16,语义与 TS 侧逐字对齐。
MVP = P·V·M 的合成顺序
三段矩阵拼成 MVP = P·V·M,作用在右侧的顶点上:MVP·p = P·(V·(M·p))。求值顺序是「先模型、再视图、最后投影」,与上一节的 T·R·S 同理——矩阵作用在右侧,最右边的先作用。合成只需两次 mat_mul,且可以预计算:MVP = mat_mul(P, V)(本场景 M 是单位阵),每顶点只做一次 mat_vec4。这是本卷顶点变换的全部工作,也是「顶点层用 BigInt、像素层用 Number」这条分界真正承受压力的地方——P·V 的中间量正是上一节实测的 2^36.65 量级。
深度归一化到 [0, ONE]
投影给出 z' 与 w' 后,ndc_z = z' / w' 落在 [-1, 1]。本卷把深度统一归一化到 [0, ONE]:depth = (ndc_z + ONE) >> 1。这样深度与顶点变换同精度(Q16.16),可以参与逐像素的整数插值与 z-buffer 比较,不必在中间引入浮点。近平面映到 depth = 0、远平面映到 depth = ONE。
完整一帧
脚本复用 sim/cglib.py 的投影、投影到屏幕与光栅化,以及 sim/render_raster.py 的 build_scene(同一份场景装配),把《烛影山谷》渲染成一帧并算出像素缓冲的 sha256。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_03_3_projection_matrix_mvp.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_03_3_projection_matrix_mvp.py
"""3.3 投影矩阵与 MVP 合成:每一项的来历、深度归一化、完整一帧。
全整数;复用 sim/cglib.py(mat_perspective / mat_mul / project / to_screen /
rasterize)与 sim/render_raster.py 的 build_scene(同一份场景装配)。
"""
import hashlib
import os
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import render_raster # noqa: E402
ONE = cglib.ONE
GOLDEN_SHA256 = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced"
def depth_of(proj, view_z):
"""视图空间 z(Q16.16)经投影矩阵归一化到 [0, ONE]。"""
clip = cglib.mat_vec4(proj, (0, 0, view_z, ONE))
if clip[3] <= 0:
return None
return (cglib.fdiv(clip[2], clip[3]) + ONE) >> 1
def main():
t0 = time.perf_counter()
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
cam = spec["camera"]
near, far = cam["nearQ16"], cam["farQ16"]
aspect = cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height))
f = cglib.fdiv(ONE, cam["tanHalfFovYQ16"])
r = cglib.fdiv(f, aspect)
m22 = cglib.fdiv(far + near, near - far)
m23 = cglib.fdiv(2 * cglib.fmul(far, near), near - far)
view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
proj = cglib.mat_perspective(cam["tanHalfFovYQ16"], aspect, near, far)
mvp = cglib.mat_mul(proj, view) # 模型矩阵 M = I:场景几何已烘焙到世界坐标
# 完整一帧:装配(同 render_raster.py)-> 顶点变换 -> 光栅化
verts, tris, vcolors = render_raster.build_scene(spec)
screen = []
for v in verts:
p = cglib.project(mvp, v)
screen.append(None if p is None else cglib.to_screen(p[0], p[1], width, height) + (p[2],))
rtris = []
dropped = 0
for a, b, c in tris:
sa, sb, sc = screen[a], screen[b], screen[c]
if sa is None or sb is None or sc is None:
dropped += 1
continue
rtris.append((
sa[0], sa[1], sa[2], *vcolors[a], sb[0], sb[1], sb[2], *vcolors[b],
sc[0], sc[1], sc[2], *vcolors[c],
))
color, _d, covered, culled = cglib.rasterize(rtris, width, height, spec["backgroundQ8"])
px = np.clip(color, 0, 255).astype(np.uint8)
digest = hashlib.sha256(px.tobytes()).hexdigest()
print(f"aspect / f / r : {aspect} / {f} / {r}")
print(f"(far+near)/(near-far) : {m22} ({m22 / ONE:+.6f})")
print(f"2·far·near/(near-far) : {m23} ({m23 / ONE:+.6f})")
print(f"proj row2 : {list(proj[8:12])}")
print(f"depth near/mid/far : {depth_of(proj, -near)} / "
f"{depth_of(proj, -(near + far) // 2)} / {depth_of(proj, -far)}")
print(f"triangles : {len(rtris)} drawn / {len(tris)} total ({culled} culled, {dropped} dropped)")
print(f"pixels covered : {int(covered.sum())}")
print(f"sha256(rgb) : {digest}")
print(f"matches golden : {digest == GOLDEN_SHA256}")
print(f"elapsed : {round((time.perf_counter() - t0) * 1000, 1)} ms")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出:
aspect / f / r : 87381 / 113512 / 85134
(far+near)/(near-far) : -66568 (-1.015747)
2·far·near/(near-far) : -66052 (-1.007874)
proj row2 : [0, 0, -66568, -66052]
depth near/mid/far : 0 / 65027 / 65535
triangles : 896 drawn / 896 total (290 culled, 0 dropped)
pixels covered : 43731
sha256(rgb) : bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
matches golden : True
elapsed : 30.5 ms
读数。A = -66568、B = -66052 都是负数,因为 near - far < 0;两项的绝对值都略大于 1,符合 A ≈ -1 - 2·near/(far-near) 的量级。深度映射 0 / 65027 / 65535 说明:近平面恰好是 0、远平面落在 65535(因定点舍入差 1 个 Q16.16 单位,未到 65536);而视图空间的算术中点 (near+far)/2 却映到 65027,也就是 0.9922——深度值高度集中在远端。这不是 bug,而是透视投影深度的固有非线性(近处分辨率高、远处分辨率低),它的精度分布与 z-fighting 留到第 10 章展开。最后一行是本节的核心:matches golden : True,这一帧的像素缓冲 sha256 与卷一基准 bac65977… 完全相同。
P 层:sha256 逐字节相同
TypeScript 侧直接复用 core/src/lantern/pipeline.ts 的 renderScene,跑同一条链,并把 S 侧产出的投影矩阵、MVP 与像素缓冲 sha256 写成断言。另配一个负例:把远平面推远 1.0,深度分布随之改变,像素缓冲必须分叉。
// file: lantern/core/test/cg_raster_03_3_projection_matrix_mvp.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_03_3_projection_matrix_mvp.test.ts'
// 3.3 投影矩阵与 MVP 合成的 P 层:与 S 层 sim/cg_raster_03_3_projection_matrix_mvp.py
// 逐项一致,并复用 core/src/lantern/pipeline.ts 跑完整一帧。
//
// 金标准 sha256 由 S 侧独立产出:
// /opt/homebrew/bin/python3 sim/cg_raster_03_3_projection_matrix_mvp.py
// 两侧是两份独立代码,结果相同是同一套定点约定的必然推论,不是抄写。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { fFromInt, fdiv } from "../src/lantern/fixed.ts";
import { matLookAt, matMul, matPerspective, matVec4, type Mat4 } from "../src/lantern/mat.ts";
import { type SceneSpec, renderScene } from "../src/lantern/pipeline.ts";
import type { Vec3 } from "../src/lantern/vec.ts";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..", "..");
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as SceneSpec;
const GOLDEN_SHA256 = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced";
const GOLDEN_PROJ: Mat4 = [
85134n, 0n, 0n, 0n,
0n, 113512n, 0n, 0n,
0n, 0n, -66568n, -66052n,
0n, 0n, -65536n, 0n,
];
const GOLDEN_MVP: Mat4 = [
85135n, 0n, 0n, 0n,
0n, 111968n, -18663n, -223903n,
0n, -10944n, -65662n, 1575496n,
0n, -10774n, -64644n, 1616100n,
];
function viewAndProj(): { view: Mat4; proj: Mat4 } {
const cam = spec.camera;
const aspect = fdiv(fFromInt(spec.width), fFromInt(spec.height));
const view = matLookAt(
cam.eyeQ16.map((n) => BigInt(n)) as Vec3,
cam.targetQ16.map((n) => BigInt(n)) as Vec3,
cam.upQ16.map((n) => BigInt(n)) as Vec3,
);
const proj = matPerspective(
BigInt(cam.tanHalfFovYQ16),
aspect,
BigInt(cam.nearQ16),
BigInt(cam.farQ16),
);
return { view, proj };
}
test("透视投影矩阵每一项与 S 层逐项相同", () => {
assert.deepEqual(viewAndProj().proj, GOLDEN_PROJ);
});
test("MVP = P·V 与 S 层逐项相同", () => {
const { view, proj } = viewAndProj();
assert.deepEqual(matMul(proj, view), GOLDEN_MVP);
});
test("深度归一化:近平面 -> 0,远平面 -> ONE(定点舍入下 65535)", () => {
const { proj } = viewAndProj();
const near = BigInt(spec.camera.nearQ16);
const far = BigInt(spec.camera.farQ16);
const zNear = matVec4(proj, [0n, 0n, -near, 65536n]);
const zFar = matVec4(proj, [0n, 0n, -far, 65536n]);
assert.equal((fdiv(zNear[2], zNear[3]) + 65536n) >> 1n, 0n);
assert.equal((fdiv(zFar[2], zFar[3]) + 65536n) >> 1n, 65535n);
});
test("【核心】完整一帧:像素缓冲 sha256 与 Python 侧逐字节相同", () => {
const frame = renderScene(spec);
assert.equal(frame.result.culled, 290);
assert.equal(frame.result.coveredCount, 43731);
assert.equal(frame.dropped, 0);
assert.equal(createHash("sha256").update(frame.result.rgb).digest("hex"), GOLDEN_SHA256);
});
test("负例:远平面推远 1.0 -> 深度分布改变,像素缓冲必须分叉", () => {
const broken = JSON.parse(JSON.stringify(spec)) as SceneSpec;
broken.camera.farQ16 = spec.camera.farQ16 + 65536; // 65536 = ONE,即 1.0 世界单位
const frame = renderScene(broken);
assert.notEqual(createHash("sha256").update(frame.result.rgb).digest("hex"), GOLDEN_SHA256);
});
$ node --test 'core/test/cg_raster_03_3_projection_matrix_mvp.test.ts'
✔ 透视投影矩阵每一项与 S 层逐项相同
✔ MVP = P·V 与 S 层逐项相同
✔ 深度归一化:近平面 -> 0,远平面 -> ONE(定点舍入下 65535)
✔ 【核心】完整一帧:像素缓冲 sha256 与 Python 侧逐字节相同
✔ 负例:远平面推远 1.0 -> 深度分布改变,像素缓冲必须分叉
ℹ tests 5 pass 5 fail 0
这条链为什么可证伪:MVP 的每一项、每一次 >> 16 的舍入、每一个三角形的绘制顺序都被固定下来,只要任一环节与 S 侧不同,像素缓冲的 sha256 立刻分叉。负例证明判据有区分力——远平面推远 1.0 会改变 B,进而改变所有深度值,z-buffer 的比较结果随之变化,像素缓冲必然不同。判据能拒绝一个「看起来合理」的改动,pass 才可信。
小结
投影矩阵把视锥压进 NDC 立方体,第三行的 A = (far+near)/(near-far) 与 B = 2·far·near/(near-far) 由「近平面映到 -1、远平面映到 +1」两个条件解出,第四行的 -1 制造透视除法所需的 w' = -z。三段拼成 MVP = P·V·M,作用在右侧、最右先作用,可预合并为两次 mat_mul。深度经 (ndc_z + ONE) >> 1 归一化到 [0, ONE],与顶点变换同精度。完整一帧跑出 sha256 bac65977…,与 Python 侧逐字节相同。至此「模型 → 视图 → 投影」三段矩阵链闭合,下一章转入旋转本身:欧拉角、四元数与朝向插值。
阅读导航:上一节:3.2 视图矩阵 · 下一节:4.1 旋转矩阵与欧拉角 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。