5.1 针孔相机模型

从小孔成像推出针孔相机模型,把 scene_spec.json 的 eyeQ16/targetQ16/upQ16/tanHalfFovYQ16/nearQ16/farQ16 换算成位置、视场角与等效焦距,用定点投影验证「同一物体距离翻倍、屏幕尺寸减半」这条不变量,并给出 Python 与 TypeScript 两侧逐位相同的屏幕高度。

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。

量值
fovY59.9997°
tanHalfFovY(Q16.16)0.577347
等效焦距(24 mm 传感器)20.7847 mm
宽高比 aspect1.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
tanHalfFovYQ16378370.577347竖直半视场角的正切
nearQ16327680.5近平面沿视轴的距离
farQ16419430464.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 透视与正交投影 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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