1.2 相机与主光线

相机三根基向量必须从 mat_look_at 的视图矩阵取行(第 0/1/2 行即 s / u / -f)。底座原先取列,把 up 的 z 与 back 的 y 各反一号,相机是歪的。判据是主光线命中掩码与卷一光栅化覆盖掩码的逐像素差:取行 43731 只差 4 个,取列 21519 差 22212。像素再经 NDC 与 4:3 宽高比合成主光线方向。

1.2 相机与主光线

站内 /graphics-raytracing-hybrid/ 那篇讲的是实时混合光追在 GPU 上的工程实现。本卷做离线光追的完整链路,每一帧的起点是同一个问题:给定相机,怎么把 320×240 个像素各自变成一条世界空间的主光线? 上一节说清了「光线是 o + t·d」,这一节说清 o 与 d 到底怎么来的,并顺带定位、修掉底座里的一个真 bug。

相机基:取 mat_look_at 的行,不是列

相机的位置、朝向、上方向由 mat_look_at(eye, target, up) 编码成一个视图矩阵。标准构造先算出三根世界空间正交基:f = normalize(target - eye)(前向)、s = normalize(cross(f, up))(右向)、u = cross(s, f)(上向),再把它们填进矩阵的三行——第 0/1/2 行就是 s / u / -f,直接可读。底座原先取的却是第 0/1/2 列(view[0]/view[4]/view[8]):对「旋转 + 平移」的刚体变换,列与行互为转置,只有当旋转部分对称时两者才相同,而本场景的相机不是对称朝向,所以两组轴并不相等。

口径右 s上 u后 -f
取行(正确)(65537, 0, 0)(0, 64645, -10775)(0, 10774, 64644)
取列(旧 bug)(65537, 0, 0)(0, 64645, 10774)(0, -10775, 64644)

两组的右向完全一致((65537, 0, 0),65537 是归一化后的 ONE 舍入值)。差异在上向与后向的 y 分量符号:取列把 up 的 z 与 back 的 y 各反了一号。而 eye → target 的单位向量是 (0, -10774, -64644)——它精确等于取行口径 back 的相反数(偏差 0),却不等于取列口径的相反数(偏差 21549)。取列的相机是歪的。

判据:与光栅化覆盖掩码的逐像素差

「哪根轴才对」不能靠眼看,得有一个独立的第三方来裁判。卷一 S 层的光栅化器渲染的正是同一个场景、同一个 MVP:它逐像素给出覆盖掩码,43731 个像素被三角形盖住。光追的主光线命中掩码与这个覆盖掩码本该几乎重合——差别只可能来自两处约定:光栅化的顶左规则与光线的半像素格心。逐像素算下来:取行命中 43731 个像素,与覆盖掩码只差 4 个(0.09‰);取列(旧 bug)命中 21519 个,差 22212 个。

取行时两条完全独立的管线在 76800 个像素上只差 4 个——这是「取行是对的」的决定性证据。取列时差了 22212 个,量级接近命中数本身,等于说那条管线根本没在对的地方成像。这个 4 不是零:光栅化的顶左规则把正好落在共享边上的像素判给固定一侧,而光线取的是像素格心,两者在三角形边界上有半像素错位。

像素 → 主光线:NDC 与宽高比

有了三根轴,每个像素 (px, py) 变成方向向量要三步。像素 → NDC:屏幕坐标 y 向下(py = 0 在顶行),NDC 的 y 向上,故 sx = (2(px - width/2) + 1) / width、sy = (2(height/2 - py) - 1) / height。分子里的 +1 / -1 是半个像素——采样点取像素格心而非格角,所以 NDC 永远到不了 ±1:顶行 py = 0 的 sy 是 +65262(略小于 ONE),底行是 -65262。

NDC → 视锥截面 → 世界方向:垂直半角由 tanHalfFovY 给出,水平半角要把宽高比乘进去,ay = sy × tanHalfFovY、ax = sx × (tanHalfFovY × aspect);本场景 tanHalfFovY = 37837(约 0.5774,对应 60° 垂直 FOV)、aspect = 87381(4:3),合成 tan_x = 50449,对应 75.18° 的水平 FOV。再把 ax / ay 贴到右、上两根轴上,沿 -back 前进:d = normalize(ax·right + ay·up - back)。注意是 -back——back 指向相机身后,视线向前,故取负。NDC 原点 (0,0) 处 ax = ay = 0,方向恰为 -back,实测偏差 0;但 320 是偶数,没有哪个整数像素落在 NDC 原点上:中心像素 (159,119) 的 NDC 是 (-1/320, 1/240),方向 (-159, -10620, -64670),与 -back 差 159 Q16.16,全部来自那半个像素的偏移。

{
  "name": "lantern-camera-probe",
  "probePixels": [
    [0, 0],
    [319, 0],
    [0, 239],
    [319, 239],
    [159, 119],
    [0, 119],
    [319, 119],
    [159, 0],
    [159, 239]
  ],
  "orthoTolQ16": 4,
  "unitTolQ16": 4
}

probePixels 取四角、四边中点与中心,覆盖整帧的极端 NDC。两个容差 orthoTolQ16 与 unitTolQ16 都取 4——比实测最坏值(都是 2)宽一倍,用来卡「基向量是否正交归一」。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_01_2_camera.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_01_2_camera.py
"""1.2 相机与主光线:把「像素 -> 主光线方向」的每一步摊成可断言的量。

**本节定位一处底座缺陷**:`mat_look_at` 返回的是**行主序**矩阵,第 0/1/2 行依次是
right / up / back 三根世界空间基。底座原先取的是第 0/1/2 **列**
(`view[0]/view[4]/view[8]`),把 up 的 z 与 back 的 y 各反一号,相机于是是歪的。

判据不是「哪种看着对」,而是**主光线命中掩码 vs 卷一光栅化覆盖掩码的逐像素差**:

  * 取行:命中 43731,与覆盖掩码只差 4 个(0.09‰,半像素格心 + 顶左规则);
  * 取列:命中 21519,与覆盖掩码差 22212 个。

取行还精确满足 `-back == normalize(eye -> target)`;取列不满足。

另外两件事:
  1. 相机基三行正交归一——模长残差、两两点积都在容差内。
  2. NDC 映射——屏幕 y 向下、NDC y 向上;像素取格心,NDC 到不了 ±1。

产物:out/cg_trace_01_2_camera.csv、out/cg_trace_01_2_camera.json
"""

import json
import math
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cgtrace  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG = os.path.join(ROOT, "config", "cg_trace_01_2_camera.json")


def basis_rows(view):
    """正确口径:`mat_look_at` 的三**行**——right / up / back(世界空间基)。"""
    return (view[0], view[1], view[2]), (view[4], view[5], view[6]), (view[8], view[9], view[10])


def basis_cols(view):
    """旧口径(bug):误取三**列**,把 up 的 z 与 back 的 y 各反一号。"""
    return (view[0], view[4], view[8]), (view[1], view[5], view[9]), (view[2], view[6], view[10])


def ray_dir(px, py, width, height, right, up, back, tan_half, tan_x):
    """像素 -> 归一化主光线方向:`ax·right + ay·up - back`,ax/ay 由 NDC 缩放。"""
    sy = cglib.fdiv(cglib.f_from_int(2 * (height // 2 - py) - 1), cglib.f_from_int(height))
    sx = cglib.fdiv(cglib.f_from_int(2 * (px - width // 2) + 1), cglib.f_from_int(width))
    ax, ay = cglib.fmul(sx, tan_x), cglib.fmul(sy, tan_half)
    return cglib.v_norm(tuple(cglib.fmul(ax, right[k]) + cglib.fmul(ay, up[k]) - back[k]
                              for k in range(3)))


def ndc(px, py, width, height):
    """像素中心 -> NDC。屏幕 y 向下,NDC y 向上;格心使 NDC 到不了 ±1。"""
    return (cglib.fdiv(cglib.f_from_int(2 * (px - width // 2) + 1), cglib.f_from_int(width)),
            cglib.fdiv(cglib.f_from_int(2 * (height // 2 - py) - 1), cglib.f_from_int(height)))


def ray_hit_mask(nodes, order, tris, eye, tmax, w, h, right, up, back, tan_half, tan_x):
    """跑满全图,返回命中像素集合(用于与光栅化覆盖掩码比对)。"""
    hits = set()
    for py in range(h):
        for px in range(w):
            d = ray_dir(px, py, w, h, right, up, back, tan_half, tan_x)
            if cgtrace.bvh_intersect(nodes, order, tris, eye, d, tmax) is not None:
                hits.add((px, py))
    return hits


def raster_coverage(spec, width, height):
    """卷一光栅化器的覆盖掩码(同一份 spec、同一个 MVP)——判定口径的金标准。"""
    import render_raster
    cam = spec["camera"]
    aspect = cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height))
    view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    proj = cglib.mat_perspective(cam["tanHalfFovYQ16"], aspect, cam["nearQ16"], cam["farQ16"])
    mvp = cglib.mat_mul(proj, view)
    verts, tris, vcolors = render_raster.build_scene(spec)
    screen = []
    for v in verts:
        p = cglib.project(mvp, v)
        if p is None:
            screen.append(None)
            continue
        sx, sy = cglib.to_screen(p[0], p[1], width, height)
        screen.append((sx, sy, p[2]))
    rtris = []
    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:
            continue
        rtris.append((sa[0], sa[1], sa[2], vcolors[a][0], vcolors[a][1], vcolors[a][2],
                      sb[0], sb[1], sb[2], vcolors[b][0], vcolors[b][1], vcolors[b][2],
                      sc[0], sc[1], sc[2], vcolors[c][0], vcolors[c][1], vcolors[c][2]))
    _color, _depth, covered, culled = cglib.rasterize(rtris, width, height, spec["backgroundQ8"])
    return covered, culled


def main():
    spec = cglib.load_spec()
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    w, h = spec["width"], spec["height"]
    eye = tuple(spec["camera"]["eyeQ16"])
    target = tuple(spec["camera"]["targetQ16"])
    tan_half = spec["camera"]["tanHalfFovYQ16"]
    aspect = cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h))
    tan_x = cglib.fmul(tan_half, aspect)

    view = cglib.mat_look_at(eye, target, tuple(spec["camera"]["upQ16"]))
    right, up, back = basis_rows(view)           # 正确口径
    c_right, c_up, c_back = basis_cols(view)     # 旧口径(bug)
    f_row = cglib.v_norm(cglib.v_sub(target, eye))
    neg_back = (-back[0], -back[1], -back[2])
    fwd_dev = max(abs(neg_back[k] - f_row[k]) for k in range(3))
    col_fwd_dev = max(abs((-c_back[k]) - f_row[k]) for k in range(3))

    unit_err = max(abs(cglib.v_dot(v, v) - cglib.ONE) for v in (right, up, back))
    dots = {"right_up": cglib.v_dot(right, up), "right_back": cglib.v_dot(right, back),
            "up_back": cglib.v_dot(up, back)}
    ortho_err = max(abs(v) for v in dots.values())
    fov_y = 2.0 * math.degrees(math.atan(tan_half / cglib.ONE))
    fov_x = 2.0 * math.degrees(math.atan(tan_x / cglib.ONE))

    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)
    tmax = spec["camera"]["farQ16"]

    rows = []
    for px, py in cfg["probePixels"]:
        sx, sy = ndc(px, py, w, h)
        d = ray_dir(px, py, w, h, right, up, back, tan_half, tan_x)
        hit = cgtrace.bvh_intersect(nodes, order, tris, eye, d, tmax)
        rows.append({"px": px, "py": py, "sxQ16": sx, "syQ16": sy, "d": d,
                     "len2": cglib.v_dot(d, d), "hit": hit is not None,
                     "t": hit[0] if hit else None})

    cx, cy = w // 2 - 1, h // 2 - 1
    d_center = ray_dir(cx, cy, w, h, right, up, back, tan_half, tan_x)
    center_dev = max(abs(d_center[k] - neg_back[k]) for k in range(3))
    # NDC 原点 (0,0) 的光线:ax = ay = 0,方向恰为 -back
    d_origin = cglib.v_norm(neg_back)
    origin_dev = max(abs(d_origin[k] - neg_back[k]) for k in range(3))

    rows_hits = ray_hit_mask(nodes, order, tris, eye, tmax, w, h, right, up, back, tan_half, tan_x)
    cols_hits = ray_hit_mask(nodes, order, tris, eye, tmax, w, h,
                             c_right, c_up, c_back, tan_half, tan_x)
    covered, culled = raster_coverage(spec, w, h)
    cov = {(px, py) for py in range(h) for px in range(w) if covered[py][px]}
    rows_diff = len(rows_hits ^ cov)
    cols_diff = len(cols_hits ^ cov)

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    # 覆盖掩码(每像素 1 字节)落盘,供 P 层逐像素复算「取行差 4 / 取列差 22212」
    with open(os.path.join(out, "cg_trace_01_2_coverage.bin"), "wb") as fh:
        fh.write(covered.astype("uint8").tobytes())
    cglib.write_csv(
        os.path.join(out, "cg_trace_01_2_camera.csv"),
        ["px", "py", "ndc_x_q16", "ndc_y_q16", "dx", "dy", "dz", "len2_q16", "hit", "t_q16"],
        [[r["px"], r["py"], r["sxQ16"], r["syQ16"], r["d"][0], r["d"][1], r["d"][2],
          r["len2"], int(r["hit"]), r["t"] if r["t"] is not None else ""] for r in rows])

    stats = {
        "width": w, "height": h, "eyeQ16": list(eye), "targetQ16": list(target),
        "tanHalfFovYQ16": tan_half, "aspectQ16": aspect, "tanXQ16": tan_x,
        "fovYDeg": round(fov_y, 4), "fovXDeg": round(fov_x, 4),
        "basisRows": {"rightQ16": list(right), "upQ16": list(up), "backQ16": list(back)},
        "basisCols": {"rightQ16": list(c_right), "upQ16": list(c_up), "backQ16": list(c_back)},
        "forwardQ16": list(f_row),
        "rowsForwardDeviationQ16": fwd_dev, "colsForwardDeviationQ16": col_fwd_dev,
        "unitResidualMaxQ16": unit_err, "orthoDotQ16": dots, "orthoResidualMaxQ16": ortho_err,
        "centerPixel": {"px": cx, "py": cy, "dirQ16": list(d_center),
                        "negBackQ16": list(neg_back), "deviationQ16": center_dev},
        "ndcOriginRayQ16": list(d_origin), "ndcOriginDeviationQ16": origin_dev,
        "probeRows": [{"px": r["px"], "py": r["py"], "sxQ16": r["sxQ16"], "syQ16": r["syQ16"],
                       "d": list(r["d"]), "len2": r["len2"], "hit": r["hit"], "t": r["t"]}
                      for r in rows],
        "rowsHitPixels": len(rows_hits), "colsHitPixels": len(cols_hits),
        "rasterCoveredPixels": len(cov), "rasterCulled": culled,
        "rowsMaskDiff": rows_diff, "colsMaskDiff": cols_diff,
        "coverageMaskFile": "out/cg_trace_01_2_coverage.bin",
    }
    with open(os.path.join(out, "cg_trace_01_2_camera.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print("== 1.2 相机与主光线 ==")
    print(f"右 / 上 / 后(取行)   : {right} / {up} / {back}")
    print(f"模长残差 max           : {unit_err} Q16.16(容差 {cfg['unitTolQ16']})")
    print(f"两两点积               : {dots}  max |dot| = {ortho_err}")
    print(f"-back 与 eye->target   : 取行偏差 {fwd_dev} / 取列偏差 {col_fwd_dev}")
    print(f"取列(旧 bug)         : 右 {c_right} 上 {c_up} 后 {c_back}")
    print(f"tanHalfFovY / aspect   : {tan_half} / {aspect}(4:3)  tan_x = {tan_x}")
    print(f"垂直 / 水平 FOV        : {fov_y:.4f}° / {fov_x:.4f}°")
    print(f"NDC 原点光线           : {d_origin}(与 -back 偏差 {origin_dev})")
    print(f"中心像素 {cx},{cy} 方向 : {d_center}  (-back 偏差 {center_dev} Q16.16,来自半像素偏移)")
    for r in rows[:4]:
        print(f"  像素 {r['px']},{r['py']}  NDC=({r['sxQ16']},{r['syQ16']})  d={r['d']}"
              f"  |d|^2={r['len2']}  命中={r['hit']}")
    print(f"光栅化覆盖掩码         : {len(cov)} 像素(culled {culled})")
    print(f"命中掩码 取行 {len(rows_hits)} / 取列 {len(cols_hits)}")
    print(f"与覆盖掩码的差 取行 {rows_diff} / 取列 {cols_diff}")
    print("csv/json               : out/cg_trace_01_2_camera.{csv,json}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一次:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_01_2_camera.py
== 1.2 相机与主光线 ==
右 / 上 / 后(取行)   : (65537, 0, 0) / (0, 64645, -10775) / (0, 10774, 64644)
模长残差 max           : 2 Q16.16(容差 4)
两两点积               : {'right_up': 0, 'right_back': 0, 'up_back': -2}  max |dot| = 2
-back 与 eye->target   : 取行偏差 0 / 取列偏差 21549
取列(旧 bug)         : 右 (65537, 0, 0) 上 (0, 64645, 10774) 后 (0, -10775, 64644)
tanHalfFovY / aspect   : 37837 / 87381(4:3)  tan_x = 50449
垂直 / 水平 FOV        : 59.9997° / 75.1775°
NDC 原点光线           : (0, -10774, -64644)(与 -back 偏差 0)
中心像素 159,119 方向 : (-159, -10620, -64670)  (-back 偏差 159 Q16.16,来自半像素偏移)
  像素 0,0  NDC=(-65331,65262)  d=(-36301, 19048, -51131)  |d|^2=65535  命中=False
  像素 319,0  NDC=(65331,65262)  d=(36300, 19049, -51131)  |d|^2=65534  命中=False
  像素 0,239  NDC=(-65331,-65262)  d=(-36301, -34603, -42188)  |d|^2=65535  命中=True
  像素 319,239  NDC=(65331,-65262)  d=(36300, -34603, -42189)  |d|^2=65535  命中=True
光栅化覆盖掩码         : 43731 像素(culled 290)
命中掩码 取行 43731 / 取列 21519
与覆盖掩码的差 取行 4 / 取列 22212
csv/json               : out/cg_trace_01_2_camera.{csv,json}

五个数字怎么读

取行的 -back 与 eye → target 偏差 0,取列偏差 21549。 相机看向目标,则「向后」必须是前向的相反数——这是相机模型的定义,不是审美。取行口径逐分量相等(v_norm 没改动任何分量,因为 |back|² 已在 ONE ± 2 内);取列口径在 y 上差了 21549 Q16.16(约 0.33 个世界单位),方向明显不对。

命中掩码与光栅化覆盖掩码只差 4 个像素。 43731 个覆盖像素里有 43727 个被主光线同样命中,另外 4 个的差别来自半像素格心与顶左规则。这是全节最硬的一条:光栅化器与光追器没有任何共享的成像代码(一个走 MVP + 屏幕空间三角形,一个走 mat_look_at 取基 + 逐像素求交),却在 76800 个像素上一致到 0.09‰。旧口径的 22212 差正好说明这个判据不是摆设。

模长残差 2、两两点积最大 2。 三根轴各自 |v|² = ONE ± 2,两两点积 |dot| ≤ 2。这是 v_norm 的舍入量级,判据用容差 4 卡住。正交归一成立,所以 ax·right + ay·up - back 是一个不扭曲的线性映射——像素矩形映到视锥截面上仍是矩形。

垂直 60°、水平 75.18°。 tanHalfFovY = 37837 ≈ 0.5774 正好是 tan 30°,垂直 FOV 是 60°。宽高比 4:3 让水平半角按 tan_x = tanHalfFovY × aspect = 50449 放大,水平 FOV 75.18°——比垂直宽 15° 出头。这两个角不是独立参数:改分辨率就必须重算 tan_x,否则画面会被拉伸。

中心像素偏 159 Q16.16。 320 与 240 都是偶数,w/2 - 1 = 159、h/2 - 1 = 119 处的 NDC 是 (-1/320, 1/240) 而不是 0,故方向 (-159, -10620, -64670) 与 -back 差 159(约 0.0024 个世界单位)。半像素偏移是主光线方向里唯一一处「非物理」的量——它来自采样约定,不是相机模型,任何「中心像素必须正对目标」的直觉在这里都不成立。

跨语言核对

P 层用 BigInt 重算相机基(取行)、正交归一性、tanHalfFovY 与宽高比的合成,以及探针像素的 NDC 与方向,与 S 侧的 golden 表逐项比对:

// snippet: 节选自 lantern/core/test/cg_trace_01_2_camera.test.ts
const view = matLookAt(eye, target, up);
const rowsBack: Vec3 = [view[8], view[9], view[10]];      // 与 S 侧同下标:取行
const negBackRows: Vec3 = [-rowsBack[0], -rowsBack[1], -rowsBack[2]];
const devRows = Math.max(...negBackRows.map((x, i) => Number(absBig(x - fwd[i]))));
assert.equal(devRows, golden.rowsForwardDeviationQ16);    // 取行偏差 0,取列 21549

负例在 P 侧重演,而且这次负例就是旧口径本身:TS 侧同时算出「取行命中掩码」与「取列命中掩码」,再读入 S 侧落盘的 out/cg_trace_01_2_coverage.bin 光栅化覆盖掩码,逐像素复算两个对称差——断言取行差 4、取列差 22212,且取行必须小于取列。这条把「口径对不对」变成了一个可以自动跑、不依赖肉眼比较的判据。

阅读导航:上一节:1.1 光线的表示与求交 · 下一节:1.3 递归与深度限制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配