3.3 投影矩阵与 MVP 合成

逐项推导透视投影矩阵的来历,含 (far+near)/(near-far) 与 2·far·near/(near-far) 的定点化;说明 MVP = P·V·M 的合成顺序与深度归一化到 [0, ONE] 的映射,并给出完整一帧渲染,断言 Python 与 TypeScript 的像素缓冲 sha256 完全相同。

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 旋转矩阵与欧拉角 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图