2.3 齐次坐标与投影
站内的 图形数学基础
推导过透视投影矩阵的形式,用浮点算到 NDC 就收尾;本节要补齐它跳过的一步——裁剪空间坐标还是齐次的,必须除以 w 才落到 NDC,而且这个 w 不是随手塞的,它恰好等于相机前方的距离。我们要把除法、w 的来源、NDC 到屏幕的定点映射和「相机后方必须拒绝」这条守卫,全部写成可断言的整数代码。
w 从哪来
透视投影矩阵的最后一行是 (0, 0, -ONE, 0)。mat_vec4 用这一行乘齐次坐标 (x, y, z, 1),得到裁剪空间的第四分量 w' = -z_view。相机在右手系里看向 -z,所以相机前方的点 z_view < 0,于是 w' > 0;w' 的大小正好是点到相机平面的距离。
为什么是「除以 w」
投影后的 x'、y'、z' 都带着这个距离因子。除以 w 就是把这个因子除掉,让远处的点收缩、近处的点铺开——这正是透视缩短的代数来源。除法得到的 x'/w、y'/w 落在 [-1, 1],即归一化设备坐标 NDC。
关键守卫:w <= 0 的点在相机后方或落在相机平面上,除法要么给出被镜像的假坐标、要么除零。所以 project() 在除法之前先判 w <= 0 并返回 None。
NDC 到屏幕:Q6 定点映射
NDC 的 [-1, 1] 映射到像素坐标,y 轴翻转(NDC 向上、屏幕向下)。to_screen 的定点实现把结果留在 Q6(1 像素 = 64 单位):
sx = ((ndc_x + ONE) * width * 64) >> 17
sy = ((ONE - ndc_y) * height * 64) >> 17
可跑的实现与验证
S 层脚本直接复用底座的 cglib.project / cglib.to_screen,搭一个小相机,投影四个点,其中一个在相机后方:
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_02_3_homogeneous_project.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_02_3_homogeneous_project.py
"""2.3 齐次坐标与投影:w 从哪来、透视除法为什么是「除以 w」、NDC 如何映射到屏幕。
复用底座 cglib.project / cglib.to_screen(不重写定点内核)。
小场景验证:相机后方的点 w <= 0 必须被拒绝(project 返回 None)。
产物:out/cg_raster_02_3_project.json
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
def main():
W, H = 320, 240
eye = (0, 6 * ONE, 24 * ONE)
target = (0, 2 * ONE, 0)
up = (0, ONE, 0)
tan_half = 37837
near, far = 32768, 4194304
aspect = cglib.fdiv(cglib.f_from_int(W), cglib.f_from_int(H))
view = cglib.mat_look_at(eye, target, up)
proj = cglib.mat_perspective(tan_half, aspect, near, far)
mvp = cglib.mat_mul(proj, view)
# 投影矩阵第 4 行 = (0, 0, -ONE, 0) -> w' = -z_view(相机看向 -z)
print(f"投影矩阵第 4 行 : {proj[12:16]}")
print(f" 故裁剪空间 w' = -z_view(相机前方 z_view<0,故 w'>0)")
# NDC 边界 -> 屏幕 Q6
print(f"to_screen(-1,-1) : {cglib.to_screen(-ONE, -ONE, W, H)} (= 左下角像素)")
print(f"to_screen( 1, 1) : {cglib.to_screen(ONE, ONE, W, H)} (= 右上角像素)")
print(f"to_screen( 0, 0) : {cglib.to_screen(0, 0, W, H)} (= 中心 160,120)")
assert cglib.to_screen(0, 0, W, H) == (W * 64 // 2, H * 64 // 2)
# 测试点:前三个在相机前方,最后一个在相机后方
pts = [
("target", (0, 2 * ONE, 0)),
("front-left", (-2 * ONE, ONE, -2 * ONE)),
("front-right", (2 * ONE, ONE, 2 * ONE)),
("behind", (0, 10 * ONE, 40 * ONE)),
]
rows = []
for name, p in pts:
vx, vy, vz, vw = cglib.mat_vec4(view, (p[0], p[1], p[2], ONE))
w_clip = -vz # 投影矩阵第 4 行
r = cglib.project(mvp, p)
if r is None:
print(f"{name:12s} z_view={vz:>10d} w'={w_clip:>10d} -> 拒绝(w<=0,相机后方)")
rows.append({"name": name, "z_view": vz, "w": w_clip, "accepted": False})
assert name == "behind", f"{name} 不应被拒绝"
continue
ndc_x, ndc_y, depth, w = r
sx, sy = cglib.to_screen(ndc_x, ndc_y, W, H)
print(
f"{name:12s} z_view={vz:>10d} w'={w:>10d} ndc=({ndc_x / ONE:+.4f},{ndc_y / ONE:+.4f})"
f" screen=({sx / 64:.2f},{sy / 64:.2f})px depth={depth}"
)
assert w > 0, "前方点的 w 必须为正"
assert -ONE <= ndc_x <= ONE and -ONE <= ndc_y <= ONE, "NDC 必须落在 [-1,1]"
rows.append({
"name": name, "z_view": vz, "w": w,
"ndc_x": ndc_x, "ndc_y": ndc_y, "sx": sx, "sy": sy, "depth": depth,
"accepted": True,
})
# 拒绝统计
accepted = sum(1 for r in rows if r["accepted"])
rejected = len(rows) - accepted
print(f"接受 {accepted} / 拒绝 {rejected}")
assert rejected == 1 and rows[-1]["accepted"] is False
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "cg_raster_02_3_project.json"), "w", encoding="utf-8") as fh:
json.dump({"width": W, "height": H, "points": rows}, fh, ensure_ascii=False, indent=2)
print("out : cg_raster_02_3_project.json")
return 0
if __name__ == "__main__":
sys.exit(main())
P 层用同一套相机与矩阵,断言四角映射、前方点的精确整数坐标,并专门验证后方点被拒绝:
// file: lantern/core/test/cg_raster_02_3_homogeneous_project.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_02_3_homogeneous_project.test.ts'
// 2.3 的 P 层:w 的来源、透视除法、NDC -> 屏幕;相机后方(w<=0)必须被拒绝。
// 金标准由 S 侧 sim/cg_raster_02_3_homogeneous_project.py 产出。
import assert from "node:assert/strict";
import { test } from "node:test";
import { ONE, fFromInt, fdiv } from "../src/lantern/fixed.ts";
import { type Mat4, matLookAt, matMul, matPerspective, matVec4 } from "../src/lantern/mat.ts";
import { project, toScreen } from "../src/lantern/project.ts";
import type { Vec3 } from "../src/lantern/vec.ts";
const W = 320;
const H = 240;
const view: Mat4 = matLookAt([0n, 6n * ONE, 24n * ONE], [0n, 2n * ONE, 0n], [0n, ONE, 0n]);
const proj: Mat4 = matPerspective(37837n, fdiv(fFromInt(W), fFromInt(H)), 32768n, 4194304n);
const mvp: Mat4 = matMul(proj, view);
test("投影矩阵第 4 行为 (0,0,-1,0),故 w' = -z_view", () => {
assert.deepEqual(proj.slice(12, 16), [0n, 0n, -ONE, 0n]);
const p: Vec3 = [0n, 2n * ONE, 0n];
const zv = matVec4(view, [p[0], p[1], p[2], ONE])[2];
const r = project(mvp, p);
assert.ok(r !== null);
assert.equal(r.w, -zv, "裁剪空间 w 等于视图空间 z 的相反数");
});
test("NDC -> 屏幕:四角与中心的 Q6 映射", () => {
assert.deepEqual(toScreen(-ONE, -ONE, W, H), [0n, 15360n]);
assert.deepEqual(toScreen(ONE, ONE, W, H), [20480n, 0n]);
assert.deepEqual(toScreen(0n, 0n, W, H), [10240n, 7680n]);
});
test("前方点的投影与 Python 侧逐位一致", () => {
const p: Vec3 = [-2n * ONE, ONE, -2n * ONE];
const r = project(mvp, p);
assert.ok(r !== null);
assert.equal(r.ndcX, -6433n);
assert.equal(r.ndcY, -2818n);
assert.equal(r.depth, 64804n);
assert.equal(r.w, 1734614n);
const [sx, sy] = toScreen(r.ndcX, r.ndcY, W, H);
assert.equal(sx, 9234n);
assert.equal(sy, 8010n);
});
test("前方点的 NDC 必须落在 [-1, 1]", () => {
for (const p of [[0n, 2n * ONE, 0n], [2n * ONE, ONE, 2n * ONE]] as Vec3[]) {
const r = project(mvp, p);
assert.ok(r !== null);
assert.ok(r.ndcX >= -ONE && r.ndcX <= ONE, "NDC x 越界");
assert.ok(r.ndcY >= -ONE && r.ndcY <= ONE, "NDC y 越界");
}
});
test("【核心】相机后方的点 w <= 0,project 必须返回 null", () => {
const behind: Vec3 = [0n, 10n * ONE, 40n * ONE];
const clip = matVec4(mvp, [behind[0], behind[1], behind[2], ONE]);
assert.ok(clip[3] <= 0n, "相机后方点的裁剪空间 w 必须 <= 0");
assert.equal(project(mvp, behind), null, "w<=0 必须被拒绝,不做除法");
});
test("负例:忽略 w 守卫强行除法 -> 后方点会落到屏幕内(错误可见)", () => {
const behind: Vec3 = [0n, 10n * ONE, 40n * ONE];
const [x, y, , w] = matVec4(mvp, [behind[0], behind[1], behind[2], ONE]);
assert.ok(w < 0n);
const badX = fdiv(x, w); // 除以负数:NDC 被镜像,得到看似合法的坐标
const [sx, sy] = toScreen(badX, 0n, W, H);
assert.ok(sx >= 0n && sx <= BigInt(W) * 64n, "错误的 NDC 仍会落进屏幕,故必须显式拒绝");
assert.ok(sy >= 0n && sy <= BigInt(H) * 64n);
});
真跑:
# snippet: 复现命令(在 lantern 工作区根执行)
cd $HOME/.cgbook/lantern
/opt/homebrew/bin/python3 sim/cg_raster_02_3_homogeneous_project.py
node --test 'core/test/cg_raster_02_3_homogeneous_project.test.ts'
S 侧输出:
投影矩阵第 4 行 : (0, 0, -65536, 0)
to_screen(-1,-1) : (0, 15360) (= 左下角像素)
to_screen( 1, 1) : (20480, 0) (= 右上角像素)
to_screen( 0, 0) : (10240, 7680) (= 中心 160,120)
target z_view= -1594552 w'= 1594552 ndc=(+0.0000,+0.0000) screen=(160.00,119.98)px depth=64694
front-left z_view= -1734614 w'= 1734614 ndc=(-0.0982,-0.0430) screen=(144.28,125.16)px depth=64804
front-right z_view= -1476038 w'= 1476038 ndc=(+0.1153,-0.1011) screen=(178.45,132.12)px depth=64585
behind z_view= 1077400 w'= -1077400 -> 拒绝(w<=0,相机后方)
接受 3 / 拒绝 1
P 侧结果:
ℹ tests 6
ℹ pass 6
ℹ fail 0
三个前方点的 w' 都等于 -z_view 且为正;后方点的 w' = -1077400,被守卫拦下。注意 target 落在 (160.00, 119.98) 而非正中 (160, 120)——差的那 0.02 像素来自 Q16.16 矩阵链的截断,定点渲染里这类半像素级的偏移是常态,不是错误。
阅读导航:上一节:2.2 点与向量的区别 · 下一节:3.1 模型矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。