14.1 两条路径的等价与分歧

光栅化与光追是两条完全独立的成像路径:一个在屏幕 Q6 空间用整数边函数判覆盖,一个在世界空间用 Möller–Trumbore 求交。同一份场景下两条路径的覆盖掩码各 43731 像素、逐像素对称差 4 个,且这 4 个全落在到最近正向边距离为 0 Q6 的轮廓边上;采样点挪半个像素,同一判据立刻变差到 160。

14.1 两条路径的等价与分歧

站内 /graphics-api-modern-comparison/ 那篇讲的是图形 API 的选型对比——把 Vulkan、Metal、D3D12、WebGPU 放在工程与生态的坐标里权衡。本章反过来做两条渲染路径的数值对照:同一份 config/scene_spec.json、同一帧 320×240,一条路径是光栅化(在屏幕空间用整数边函数判覆盖),一条是光追(在世界空间用 Möller–Trumbore 求交)。两条路径不共用任何成像代码,那么它们的输出能一致到什么程度、在哪里分叉、分叉多少个像素?这一节先把「等价」量出来,再把「分歧」定位到边界上。

两条路径共享什么,不共享什么

环节光栅化路径(卷一)光追路径(本卷)
几何世界空间顶点经 MVP 投到屏幕 Q6顶点留在世界空间,建 BVH
可见性逐像素整数边函数判覆盖逐像素发一条主光线,命中即覆盖
共享边顶左规则判给唯一一侧不适用(逐像素独立裁决)
背面area <= 0 剔除(896 里剔 290)不剔除,靠法线朝向自然给出
颜色屏幕空间插值法线 + albedo命中点法线 + albedo 直接着色
采样点像素格心(Q6 的 +32)NDC 里的半像素 ±1

共享的是输入:同一份场景、同一份几何、同一份顶点法线、同一个 mat_look_at 相机、同一个视锥。不共享的是可见性判定本身——一个在屏幕空间数格子,一个在世界空间打光线。正因为如此,两条路径的覆盖掩码可以逐像素相减,而这个对称差就是「两个独立实现是否等价」最直接的证据。

判据:覆盖掩码的逐像素对称差

光追掩码 43731 个像素、光栅化掩码 43731 个像素(各占全图 569.4141‰),逐像素对称差 4——占覆盖像素的 0.0915‰、占全图的 0.0521‰。这 4 个像素是两条路径唯一的差别,本节的全部工作就是把它们定位清楚。

定位结果:4 个像素全部落在轮廓边上(只被 1 个正面三角形共用),到最近正向边的距离全是 0 Q6——格心压在投影边的直线上或紧贴其外侧。其中 2 个像素的有符号边函数是 0(格心正好落在直线,光栅化的 >= 0 把它算作覆盖),另 2 个是 -1 Q6(格心在边外侧不到 1/64 像素,光栅化正确地排除它,而光线的浮点余量落在内侧)。共享边上的分歧是 0 个:顶左规则把共享边判给唯一一侧,与光追「谁近算谁」并不冲突。

{
  "name": "lantern-two-paths",
  "halfPixelQ6": 32,
  "bandSweepQ6": [1, 2, 4, 8, 16, 32, 64, 128],
  "expectedMaskDiff": 4,
  "expectedCovered": 43731,
  "edgeProbeMaxPixels": 8
}

halfPixelQ6 = 32 就是半像素的 Q6 值(1 px = 64 Q6 单位),负例用它把采样点从格心挪到格角;bandSweepQ6 是边界带的 δ 扫描序列,从 1/64 像素一路扫到 2 像素;两个 expected* 是判据要撞上的目标值。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_14_1_two_paths.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_14_1_two_paths.py
"""14.1《两条路径的等价与分歧》:把光栅化与光追两条**完全独立**的成像路径摆在同一份
场景规格上,逐像素比对覆盖掩码,并把仅有的几个分歧像素定位到**边界**。

两条路径共享同一份 `config/scene_spec.json`、同一份几何、同一份顶点法线、同一个
`mat_look_at` 相机;**不共享**的是可见性判定本身——光栅化器在屏幕 Q6 空间用整数边函数
判覆盖(顶左规则决定共享边归属),光追器在世界空间用 Möller–Trumbore 求交(命中即覆盖)。

本脚本算三件事:
  1. **等价**:两条路径的覆盖掩码各 43731 个像素,逐像素对称差 **4**(占覆盖 0.09‰)。
  2. **分歧定位**:列出那 4 个像素的坐标、双方判定、到最近正向边的有符号距离与边的共用次数。
  3. **分歧只可能出现在边界**:对全图算「到最近正向边的距离」场(Q6)后做 δ 扫描,
     验证分歧集合 D ⊆ {距离 ≤ δ},并给出包含 D 的最小 δ;负例把采样点挪半个像素。

产物:out/cg_trace_14_1_two_paths.json、out/cg_trace_14_1_divergent.csv、out/cg_trace_14_1_band.csv
"""

import json
import os
import sys

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib  # noqa: E402
import cg_trace_14_common as C  # noqa: E402

CONFIG = os.path.join(ROOT, "config", "cg_trace_14_1_two_paths.json")
OUT = os.path.join(ROOT, "out")
COV_BIN = os.path.join(OUT, "cg_trace_01_2_coverage.bin")


def main():
    spec = cglib.load_spec()
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    width, height = spec["width"], spec["height"]
    os.makedirs(OUT, exist_ok=True)

    ray_mask = C.ray_hit_mask(spec, width, height)
    tris, _ws, _total, _dropped = C.build_scene(spec, width, height)
    edges = C.edges_of(tris)
    raster_mask, _owner, *_rest, culled = C.rasterize(tris, width, height)
    inc = C.incidence(edges)

    # 与第 1 章代理落盘的覆盖掩码核对:同一口径必须逐字节相同
    bin_ok = None
    if os.path.isfile(COV_BIN):
        raw = np.frombuffer(open(COV_BIN, "rb").read(), dtype=np.uint8)
        bin_ok = bool(np.array_equal(raw.reshape(height, width) != 0, raster_mask))

    diff = np.argwhere(ray_mask ^ raster_mask)
    n_ray, n_raster = int(ray_mask.sum()), int(raster_mask.sum())

    divergent = []
    for py, px in diff:
        det = C.nearest_edge(edges, inc, int(px), int(py))
        divergent.append({
            "px": int(px), "py": int(py),
            "rayHit": bool(ray_mask[py, px]), "rasterCovered": bool(raster_mask[py, px]),
            "nearestEdgeDistQ6": det["distQ6"], "nearestEdgeSignedQ6": det["signedQ6"],
            "nearestEdgeLenQ6": det["lenQ6"], "nearestTri": det["tri"],
            "nearestEdgeIdx": det["edge"], "nearestEdgeUseCount": det["edgeUseCount"],
            "nearestEdgeA": list(det["edgeA"]), "nearestEdgeB": list(det["edgeB"]),
            "nearestTriAreaQ6x2": det["areaQ6x2"],
        })
    divergent.sort(key=lambda r: (r["py"], r["px"]))

    mind = C.distance_field(edges, width, height, cfg["halfPixelQ6"])
    sweep = []
    for d in cfg["bandSweepQ6"]:
        inside = mind <= d
        n_band = int(inside.sum())
        sweep.append({"deltaQ6": d, "bandPixels": n_band,
                      "bandPermille": round(n_band * 1000.0 / mind.size, 4),
                      "divergentOutside": int((~inside & (ray_mask ^ raster_mask)).sum())})
    containing = next((r["deltaQ6"] for r in sweep if r["divergentOutside"] == 0), None)
    worst_div = max((r["nearestEdgeDistQ6"] for r in divergent), default=0)

    # 负例:把光线采样点从格心挪到格角(半个像素),同一判据必须立刻变差
    corner_mask = C.ray_hit_mask(spec, width, height, half_px=False)
    corner_diff, corner_hits = int((corner_mask ^ raster_mask).sum()), int(corner_mask.sum())

    stats = {
        "width": width, "height": height, "pixels": width * height,
        "geometry": {
            "trianglesDrawn": len(edges),
            "frontFacingTriangles": sum(1 for e in edges if C.area2(e) > 0),
            "culled": int(culled),
        },
        "rayCoveredPixels": n_ray,
        "rasterCoveredPixels": n_raster,
        "rayCoveredPermille": round(n_ray * 1000.0 / (width * height), 4),
        "rasterCoveredPermille": round(n_raster * 1000.0 / (width * height), 4),
        "maskDiffPixels": len(divergent),
        "maskDiffPermille": round(len(divergent) * 1000.0 / (width * height), 4),
        # 本卷口径:分歧占比按**覆盖像素数**为基(43731),不是按全图像素数
        "maskDiffPermilleOfCovered": round(len(divergent) * 1000.0 / max(n_raster, 1), 4),
        "divergentOnSharedEdge": sum(1 for r in divergent if r["nearestEdgeUseCount"] >= 2),
        "divergentOnSilhouette": sum(1 for r in divergent if r["nearestEdgeUseCount"] < 2),
        "coverageBinMatchesRecomputed": bin_ok,
        "coverageBinFile": "out/cg_trace_01_2_coverage.bin",
        "halfPixelQ6": cfg["halfPixelQ6"],
        "divergent": divergent,
        "divergentWorstEdgeDistQ6": worst_div,
        "divergentWorstEdgeDistPixels": round(worst_div / cglib.SUB1, 6),
        "bandSweep": sweep,
        "smallestBandContainingAllDivergentQ6": containing,
        "negativeCornerSample": {
            "rayCoveredPixels": corner_hits, "maskDiffPixels": corner_diff,
            "maskDiffPermilleOfCovered": round(corner_diff * 1000.0 / max(n_raster, 1), 4),
            "note": "光线采样点从格心挪到格角(半个像素),判据必须变差",
        },
    }
    with open(os.path.join(OUT, "cg_trace_14_1_two_paths.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    cglib.write_csv(
        os.path.join(OUT, "cg_trace_14_1_divergent.csv"),
        ["px", "py", "ray_hit", "raster_covered", "nearest_edge_dist_q6", "nearest_edge_signed_q6",
         "nearest_edge_len_q6", "nearest_tri", "nearest_edge_idx", "nearest_edge_use_count"],
        [[r["px"], r["py"], int(r["rayHit"]), int(r["rasterCovered"]), r["nearestEdgeDistQ6"],
          r["nearestEdgeSignedQ6"], r["nearestEdgeLenQ6"], r["nearestTri"], r["nearestEdgeIdx"],
          r["nearestEdgeUseCount"]] for r in divergent])
    cglib.write_csv(
        os.path.join(OUT, "cg_trace_14_1_band.csv"),
        ["delta_q6", "band_pixels", "band_permille", "divergent_outside"],
        [[r["deltaQ6"], r["bandPixels"], r["bandPermille"], r["divergentOutside"]] for r in sweep])

    print("== 14.1 两条路径的等价与分歧 ==")
    print(f"绘制三角形             : {len(edges)}(正面 {stats['geometry']['frontFacingTriangles']}"
          f",背面剔除 {culled})")
    print(f"光追覆盖掩码           : {n_ray} 像素({stats['rayCoveredPermille']}‰)")
    print(f"光栅化覆盖掩码         : {n_raster} 像素({stats['rasterCoveredPermille']}‰)")
    print(f"逐像素对称差           : {len(divergent)}(占覆盖 {stats['maskDiffPermilleOfCovered']}‰"
          f" / 占全图 {stats['maskDiffPermille']}‰)")
    print(f"第 1 章落盘掩码一致    : {bin_ok}")
    print(f"分歧像素:共享边 {stats['divergentOnSharedEdge']} / 轮廓边 "
          f"{stats['divergentOnSilhouette']}")
    for r in divergent:
        print(f"  分歧像素 ({r['px']:3d},{r['py']:3d})  光追命中={r['rayHit']}"
              f"  光栅覆盖={r['rasterCovered']}  到最近边 {r['nearestEdgeDistQ6']} Q6"
              f"(有符号 {r['nearestEdgeSignedQ6']},边被 {r['nearestEdgeUseCount']} 个正面三角形共用)")
    print(f"分歧像素到边的最坏距离 : {worst_div} Q6 = {stats['divergentWorstEdgeDistPixels']} px")
    print("δ 扫描(Q6 -> 边界带像素数 / 带外分歧数):")
    for r in sweep:
        print(f"  δ={r['deltaQ6']:4d}  带 {r['bandPixels']:6d} 像素({r['bandPermille']}‰)"
              f"  带外分歧 {r['divergentOutside']}")
    print(f"包含全部分歧的最小 δ   : {containing} Q6")
    print(f"负例 格角采样          : 命中 {corner_hits},与覆盖掩码差 {corner_diff}"
          f"({stats['negativeCornerSample']['maskDiffPermilleOfCovered']}‰)")
    print("json/csv               : out/cg_trace_14_1_two_paths.json 等 3 个")
    return 0


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

跑一次:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_14_1_two_paths.py
== 14.1 两条路径的等价与分歧 ==
绘制三角形             : 896(正面 606,背面剔除 290)
光追覆盖掩码           : 43731 像素(569.4141‰)
光栅化覆盖掩码         : 43731 像素(569.4141‰)
逐像素对称差           : 4(占覆盖 0.0915‰ / 占全图 0.0521‰)
第 1 章落盘掩码一致    : True
分歧像素:共享边 0 / 轮廓边 4
  分歧像素 (142, 94)  光追命中=False  光栅覆盖=True  到最近边 0 Q6(有符号 0,边被 1 个正面三角形共用)
  分歧像素 (177, 94)  光追命中=True  光栅覆盖=False  到最近边 0 Q6(有符号 -1,边被 1 个正面三角形共用)
  分歧像素 ( 69,105)  光追命中=False  光栅覆盖=True  到最近边 0 Q6(有符号 0,边被 1 个正面三角形共用)
  分歧像素 (307,114)  光追命中=True  光栅覆盖=False  到最近边 0 Q6(有符号 -1,边被 1 个正面三角形共用)
分歧像素到边的最坏距离 : 0 Q6 = 0.0 px
δ 扫描(Q6 -> 边界带像素数 / 带外分歧数):
  δ=   1  带    798 像素(10.3906‰)  带外分歧 0
  δ=   2  带   1158 像素(15.0781‰)  带外分歧 0
  δ=   4  带   1865 像素(24.2839‰)  带外分歧 0
  δ=   8  带   3091 像素(40.2474‰)  带外分歧 0
  δ=  16  带   5414 像素(70.4948‰)  带外分歧 0
  δ=  32  带   9210 像素(119.9219‰)  带外分歧 0
  δ=  64  带  14664 像素(190.9375‰)  带外分歧 0
  δ= 128  带  21390 像素(278.5156‰)  带外分歧 0
包含全部分歧的最小 δ   : 1 Q6
负例 格角采样          : 命中 43587,与覆盖掩码差 160(3.6587‰)
json/csv               : out/cg_trace_14_1_two_paths.json 等 3 个

五个数字怎么读

对称差 4,占覆盖像素 0.0915‰。 两条路径没有任何共享的成像代码,却在 76800 个像素上一致到万分之九。这不是巧合:输入完全相同,可见性的定义也相同(「从格心看出去,第一个交点是谁」),差别只在数值实现——屏幕空间的整数边函数对世界空间的定点求交。

4 个分歧像素全部在轮廓边上,到边距离 0 Q6。 轮廓边是「一侧有三角形、一侧是背景」的边。分歧全部集中在这里,说明它不是光照、不是插值、不是深度,而是边界裁决:格心正好压在投影边的直线上时,>= 0 与浮点余量可能给出不同答案。

δ=1 Q6 的边界带只有 798 个像素(10.39‰),却装下了全部分歧。 δ 从 1 扫到 128,带外分歧恒为 0。分歧集合被限制在「到边 ≤ 1/64 像素」的薄带内,而这条带只占全图的 1%。所以「分歧只可能出现在边界」不是修辞,是一个可以扫出来的包含关系。

负例:采样点从格心挪到格角(半个像素),对称差从 4 变成 160。 只挪半个像素,同一判据立刻变差 40 倍,且命中数从 43731 掉到 43587。判据不被负例拒绝,「差 4」才可信;否则它可能只是「两条路径恰好都错了同一个方向」。

896 个三角形里正面 606、背面剔除 290。 光栅化靠屏幕空间有向面积 area <= 0 剔背面,光追不剔除——背面三角形的法线背向光线,ndl 自然为 0,不需要额外的剔除步骤。两条路径到达同一个结果,用的是两套完全不同的机制,这正是这一章想量清楚的东西。

跨语言核对

P 层用 BigInt 重算光追命中掩码,与 S 侧落盘的 out/cg_trace_01_2_coverage.bin 逐像素比对,并在 TS 侧独立重算那 4 个像素「到最近正向边的距离」:

// snippet: 节选自 lantern/core/test/cg_trace_14_1_two_paths.test.ts
test("分歧只出现在边界:4 个像素到最近正向边的距离全为 0,且都在轮廓边上", () => {
  assert.equal(golden.divergentWorstEdgeDistQ6, 0);
  for (const r of golden.divergent) {
    const n = nearestEdge(r.px, r.py);          // TS 侧用整数边函数重算 Q6 距离
    assert.equal(n.distQ6, 0, `(${r.px},${r.py}) 到边距离`);
    assert.equal(n.useCount, 1, `(${r.px},${r.py}) 应在轮廓边上`);
  }
});

负例也在 P 侧重演:hitMask(false) 把采样点挪到格角,断言对称差等于 S 侧落盘的 160,且必须严格大于 4。这样「4 个像素」就不是一个孤立的观测值,而是一条带正反两面的判据。

阅读导航:上一节:13.3 跨语言对照 · 下一节:14.2 CPU 与 GPU 的差异 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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