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 的差异 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。