5.1 针孔相机模型
渲染管线里的「相机」通常是一个可调参数的黑盒,本章要把它拆成可核算的解析量:位置、朝向、视场角、近远平面。如果你更关心 GPU 端如何用视锥做批量剔除,站内的 GPU 剔除与可见性系统 讲的是同一套视锥在硬件与驱动里的判定;本章关心的是它的解析构造与数值——投影矩阵怎么来的、近远平面取什么值才不让深度缓冲崩掉。这一节先把相机还原成针孔模型。
小孔成像:从相似三角形到投影矩阵
针孔相机的全部几何只有一句话:物体上一点、小孔、成像平面上一点三点共线。把相机放在原点、看向 -z,物体上一点在相机坐标系里是 (x_v, y_v, z_v),其中 z_v < 0;成像平面取在 z = -1,则成像点是 (x_v / (-z_v), y_v / (-z_v))。这就是相似三角形——屏幕上的偏移与到相机的距离成反比。
投影矩阵把这个除法写进齐次坐标:透视矩阵把 z 相关的量放进第三行、把 -1 放进最后一行,于是裁剪空间的第四分量 w = -z_v。光栅化前对每个顶点做一次「除以 w」,才得到 NDC。矩阵里的 f = 1 / tan(fovY/2) 是缩放因子,它把视场角固定下来:tan(fovY/2) 越大,视野越广,同样的物体在屏幕上越小。
据此可以立刻写出一条不变量:同一物体、到相机距离翻倍,屏幕尺寸减半。因为屏幕尺寸与 1/距离 成正比。这不是近似,而是针孔模型的严格推论;下面的脚本就在定点算术里验证它。
视野角、传感器尺寸与焦距
摄影里的「焦距」与图形学里的「视场角」是同一件事的两种说法。设传感器高度为 h、焦距为 F,则 tan(fovY/2) = (h/2) / F,反解出 F = (h/2) / tan(fovY/2)。
《烛影山谷》的规格把视场角直接存成正切 tanHalfFovYQ16 = 37837,即 0.577347,对应 fovY = 59.9997°,恰好是 60°。若把它当作 24 mm 高的全画幅传感器,等效焦距是 20.7847 mm。
| 量 | 值 |
|---|---|
| fovY | 59.9997° |
| tanHalfFovY(Q16.16) | 0.577347 |
| 等效焦距(24 mm 传感器) | 20.7847 mm |
| 宽高比 aspect | 1.333328 |
相机字段的物理含义与量级
scene_spec.json 的相机是一个全定点结构,每个字段都是 Q16.16,除以 65536 就是它的物理量:
| 字段 | 原始值 | 物理值 | 含义 |
|---|---|---|---|
eyeQ16 | [0, 393216, 1572864] | (0, 6, 24) | 相机在世界空间的位置 |
targetQ16 | [0, 131072, 0] | (0, 2, 0) | 视线注视点,与 eye 共同定朝向 |
upQ16 | [0, 65536, 0] | (0, 1, 0) | 上方向参考,定 roll |
tanHalfFovYQ16 | 37837 | 0.577347 | 竖直半视场角的正切 |
nearQ16 | 32768 | 0.5 | 近平面沿视轴的距离 |
farQ16 | 4194304 | 64.0 | 远平面沿视轴的距离 |
eye 到 target 的距离是 24.3311 世界单位;tanHalfFovYQ16 决定投影矩阵的 f 分量;nearQ16/farQ16 决定深度如何映射(5.3 节详谈)。
用脚本验证不变量
sim/cg_raster_05_1_pinhole.py 用规范针孔(眼在原点、看 -z)把一条半高 1.0 的竖直线段放到距离 D 处,经 spec 的 tanHalfFovY / aspect / near / far 投影,量出屏幕竖直跨度:
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_05_1_pinhole.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_05_1_pinhole.py
"""5.1 针孔相机模型:把 config/scene_spec.json 的相机字段换算成物理量,
并用定点投影验证「同一物体距离翻倍,屏幕尺寸减半」这条针孔不变量。
产物:
out/cg_raster_05_1_pinhole.json 相机字段量级核算 + 不变量结果
out/cg_raster_05_1_pinhole.csv 距离 -> 屏幕高度(Q6 与像素)与半衰比
"""
import json
import math
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
# 全画幅传感器:36 x 24 mm。用于把「视野角」换算成「等效焦距」。
SENSOR_H_MM = 24.0
def q16(v):
"""Q16.16 -> 十进制(仅用于报告,不参与任何定点路径)。"""
return v / ONE
def screen_height_q6(mvp, p_top, p_bot, width, height):
"""把一条竖直世界线段的两个端点投影到屏幕,返回竖直方向的 Q6 像素跨度。"""
a = cglib.project(mvp, p_top)
b = cglib.project(mvp, p_bot)
if a is None or b is None:
return None
_, ay = cglib.to_screen(a[0], a[1], width, height)
_, by = cglib.to_screen(b[0], b[1], width, height)
return abs(ay - by)
def main():
spec = cglib.load_spec()
cam = spec["camera"]
width = spec["width"]
height = spec["height"]
eye = tuple(cam["eyeQ16"])
target = tuple(cam["targetQ16"])
up = tuple(cam["upQ16"])
tan_half = cam["tanHalfFovYQ16"]
near = cam["nearQ16"]
far = cam["farQ16"]
aspect = cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height))
view = cglib.mat_look_at(eye, target, up)
proj = cglib.mat_perspective(tan_half, aspect, near, far)
mvp = cglib.mat_mul(proj, view)
# ---- 相机字段的物理含义与量级核算 --------------------------------
tan_half_f = q16(tan_half)
fov_y_deg = 2.0 * math.degrees(math.atan(tan_half_f))
focal_mm = (SENSOR_H_MM / 2.0) / tan_half_f # 24mm 高的全画幅传感器
eye_f = [q16(v) for v in eye]
target_f = [q16(v) for v in target]
dist = math.sqrt(sum((eye_f[i] - target_f[i]) ** 2 for i in range(3)))
fields = {
"eyeQ16": {"raw": eye, "world": eye_f, "meaning": "相机在世界空间的位置"},
"targetQ16": {"raw": target, "world": target_f, "meaning": "视线注视点(与 eye 共同定朝向)"},
"upQ16": {"raw": up, "world": [q16(v) for v in up], "meaning": "上方向参考(定 roll)"},
"tanHalfFovYQ16": {
"raw": tan_half,
"world": tan_half_f,
"meaning": "竖直半视场角的正切 tan(fovY/2),投影矩阵的 f 分量 = 1/tan",
},
"nearQ16": {"raw": near, "world": q16(near), "meaning": "近平面沿视轴的距离"},
"farQ16": {"raw": far, "world": q16(far), "meaning": "远平面沿视轴的距离"},
}
derived = {
"aspectQ16": aspect,
"aspect": q16(aspect),
"fovY_deg": round(fov_y_deg, 4),
"focal_mm_ff24": round(focal_mm, 4),
"eye_target_distance": round(dist, 4),
"near_over_far": round(q16(near) / q16(far), 6),
}
# ---- 不变量:距离翻倍,屏幕尺寸减半 ------------------------------
# 规范针孔模型:眼在原点、看 -z、线段垂直于视轴(沿相机 y 轴)。
# 投影矩阵仍用 spec 的 tanHalfFovY/aspect/near/far 驱动。
# 此时屏幕高度 h(D) = height * tan(半高)/(D*tanHalfFovY) 严格正比于 1/D。
eye0 = (0, 0, 0)
target0 = (0, 0, -ONE)
up0 = (0, ONE, 0)
view0 = cglib.mat_look_at(eye0, target0, up0)
mvp0 = cglib.mat_mul(proj, view0)
half_h = ONE # 线段半高 1.0 世界单位 -> 全高 2.0
rows = []
for d_int in (2, 4, 8, 16, 32, 64):
dq = cglib.f_from_int(d_int)
z = -dq
p_top = (0, half_h, z)
p_bot = (0, -half_h, z)
h_q6 = screen_height_q6(mvp0, p_top, p_bot, width, height)
rows.append({"distance": d_int, "height_q6": h_q6, "height_px": h_q6 / 64.0})
ratios = []
for i in range(len(rows) - 1):
r = rows[i + 1]["height_px"] / rows[i]["height_px"]
ratios.append({"from": rows[i]["distance"], "to": rows[i + 1]["distance"], "ratio": round(r, 6)})
# 声明容差:屏幕高度落在 Q6 整数栅格上,最近一档(D=64)只有 416 个 Q6 单位,
# ±1 单位的量化误差上限约 1/416 = 0.0024;取 0.005 作声明容差。
max_dev = max(abs(r["ratio"] - 0.5) for r in ratios)
tol = 0.005
out_dir = os.path.join(ROOT, "out")
cglib.write_csv(
os.path.join(out_dir, "cg_raster_05_1_pinhole.csv"),
["distance", "height_q6", "height_px"],
[[r["distance"], r["height_q6"], f"{r['height_px']:.4f}"] for r in rows],
)
summary = {
"camera_fields": fields,
"derived": derived,
"invariant": {
"object_height_world": 2.0,
"rows": rows,
"ratios": ratios,
"tolerance": tol,
"max_abs_deviation_from_half": round(max_dev, 6),
"pass": max_dev <= tol,
},
}
with open(os.path.join(out_dir, "cg_raster_05_1_pinhole.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=2)
print(f"eye : {eye_f} target: {target_f} dist={dist:.4f}")
print(f"tanHalfFovY : {tan_half} ({tan_half_f:.6f}) -> fovY = {fov_y_deg:.4f} deg")
print(f"focal(24mm) : {focal_mm:.4f} mm near/far = {q16(near)}/{q16(far)}")
print(f"aspect : {aspect} ({q16(aspect):.6f})")
for r in rows:
print(f" D={r['distance']:>2} 屏幕高 = {r['height_q6']:>6} Q6 = {r['height_px']:.4f} px")
for r in ratios:
print(f" h({r['to']})/h({r['from']}) = {r['ratio']:.6f}")
print(f"max |ratio-0.5| = {max_dev:.6f} (容差 {tol}) -> {'PASS' if max_dev <= tol else 'FAIL'}")
return 0
if __name__ == "__main__":
sys.exit(main())
真跑输出:
eye : [0.0, 6.0, 24.0] target: [0.0, 2.0, 0.0] dist=24.3311
tanHalfFovY : 37837 (0.577347) -> fovY = 59.9997 deg
focal(24mm) : 20.7847 mm near/far = 0.5/64.0
aspect : 87381 (1.333328)
D= 2 屏幕高 = 13303 Q6 = 207.8594 px
D= 4 屏幕高 = 6651 Q6 = 103.9219 px
D= 8 屏幕高 = 3325 Q6 = 51.9531 px
D=16 屏幕高 = 1663 Q6 = 25.9844 px
D=32 屏幕高 = 831 Q6 = 12.9844 px
D=64 屏幕高 = 415 Q6 = 6.4844 px
h(4)/h(2) = 0.499962
h(8)/h(4) = 0.499925
h(16)/h(8) = 0.500150
h(32)/h(16) = 0.499699
h(64)/h(32) = 0.499398
max |ratio-0.5| = 0.000602 (容差 0.005) -> PASS
距离从 2 到 64 连翻五番,半衰比全落在 0.5 的 0.0007 以内,最大偏差 0.000602。剩余误差来自屏幕坐标落在 Q6 整数栅格上的量化——最近一档只有 415 个 Q6 单位,±1 单位的量化上限就有约 0.0024。
跨语言复算
同一份规格在 TypeScript 定点内核下复算,六档屏幕高度与 Python 逐位相同。核心断言:
// snippet: 5.1 P 层核心断言(完整文件 core/test/cg_raster_05_1_pinhole.test.ts)
// 金标准由 S 侧 sim/cg_raster_05_1_pinhole.py 产出
const GOLDEN_HEIGHT_Q6: [number, number][] = [
[2, 13303], [4, 6651], [8, 3325], [16, 1663], [32, 831], [64, 415],
];
test("屏幕高度与 Python 侧 S 层逐位相同", () => {
for (const [d, golden] of GOLDEN_HEIGHT_Q6) {
assert.equal(heightQ6(d), golden);
}
});
test("不变量:距离翻倍,屏幕尺寸减半(容差 0.005)", () => {
let maxDev = 0;
for (let i = 0; i + 1 < GOLDEN_HEIGHT_Q6.length; i += 1) {
const ratio = heightQ6(GOLDEN_HEIGHT_Q6[i + 1][0]) / heightQ6(GOLDEN_HEIGHT_Q6[i][0]);
maxDev = Math.max(maxDev, Math.abs(ratio - 0.5));
}
assert.ok(maxDev <= 0.005);
});
node --test 'core/test/cg_raster_05_1_pinhole.test.ts'
ℹ tests 4 ℹ pass 4 ℹ fail 0
阅读导航:上一节:4.3 朝向的插值 · 下一节:5.2 透视与正交投影 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。