本附录是全卷的查阅表:规格有哪些字段、每个字段是什么量纲、定点数怎么约定、哪些不变量必须成立、各章的产物放在哪。正文里所有断言都建立在这几张表上,遇到「这个数是多少、为什么是这个数」的问题,先查这里。
A.1 场景规格字段全表
config/scene_spec.json 共 71 个字段,类型分布是 70 个整数 + 1 个字符串(name),浮点数为 0。这不是风格偏好,而是硬约束:只要出现一个浮点,Python 与 TypeScript 的逐字节一致就失去保证。
量纲后缀的含义:Q16 = Q16.16 定点(值 ÷ 65536 得实际数),Q8 = 8 位通道(0–255),无后缀 = 纯整数。
渲染参数与全局
| 字段 | 值 | 量纲 | 说明 |
|---|---|---|---|
name | lantern-hollow | str | 场景标识 |
seed | 20261015 | int | 噪声与场景生成的种子 |
width / height | 320 / 240 | int | 画布尺寸(像素) |
subpixelBits | 6 | int | 子像素位数,1 像素 = 2⁶ = 64 单位 |
depthBits | 16 | int | 深度缓冲的位宽 |
backgroundQ8 | [12,14,20] | Q8 | 背景色,三通道 |
ambientQ16 | 13107 | Q16 | 环境光项(≈0.2000) |
相机(camera)
| 字段 | 值 | 量纲 | 说明 |
|---|---|---|---|
eyeQ16 | [0, 393216, 1572864] | Q16 | 相机位置(世界 0 / 6 / 24) |
targetQ16 | [0, 131072, 0] | Q16 | 注视点(世界 0 / 2 / 0) |
upQ16 | [0, 65536, 0] | Q16 | 上方向 |
tanHalfFovYQ16 | 37837 | Q16 | 半视场角正切(≈0.5774,即 60° 竖直视场) |
nearQ16 / farQ16 | 32768 / 4194304 | Q16 | 近远平面(0.5 / 64) |
光照(directionalLight / pointLight)
| 字段 | 值 | 量纲 | 说明 |
|---|---|---|---|
directionalLight.dirQ16 | [-37837,37837,37837] | Q16 | 方向光朝向(已归一化) |
directionalLight.colorQ8 | [255,236,200] | Q8 | 方向光颜色(暖白) |
directionalLight.intensityQ16 | 58982 | Q16 | 方向光强度(≈0.9000) |
pointLight.posQ16 | [327680,524288,327680] | Q16 | 点光源位置(世界 5 / 8 / 5) |
pointLight.colorQ8 | [255,170,90] | Q8 | 点光源颜色(橙) |
pointLight.intensityQ16 | 65536 | Q16 | 点光源强度(1.0) |
pointLight.rangeQ16 | 1048576 | Q16 | 点光源作用半径(16.0) |
卷一只用方向光与环境光。 点光源字段在规格里就位、可被校验,但卷一的着色器不消费它——点光源、衰减与多光源求值是卷二的内容。这样安排是为了让规格一次成型:卷二不会改规格,只会多消费一个字段。
地形(terrain)
| 字段 | 值 | 量纲 | 说明 |
|---|---|---|---|
gridN | 16 | int | 高度场格点数(16×16 顶点) |
extentQ16 | 1441792 | Q16 | 地形边长(22.0 世界单位) |
heightScaleQ16 | 196608 | Q16 | 高度振幅(3.0) |
noiseSeed | 1337 | int | 高度噪声种子 |
albedoQ8 | [72,82,66] | Q8 | 低处反照率(深绿) |
albedoHighQ8 | [158,152,132] | Q8 | 高处反照率(浅灰黄) |
高度场用整数噪声 + 双线性插值 + smoothstep 生成,顶点色按高度在 albedoQ8 与 albedoHighQ8 之间线性插值。地形法线在卷一统一取竖直向上——这是最小模型,法线解析留到卷二。
球体(spheres,3 个)
| 字段 | spheres[0] | spheres[1] | spheres[2] | 量纲 |
|---|---|---|---|---|
centerQ16 | [-393216,196608,0] | [393216,131072,196608] | [0,262144,-262144] | Q16 |
radiusQ16 | 131072 | 98304 | 163840 | Q16 |
albedoQ8 | [220,90,70] | [90,180,220] | [200,200,190] | Q8 |
subdiv | 2 | 2 | 2 | int |
球体用八面体细分生成(subdiv=2 时每个球 128 个三角形),刻意避开三角函数——定点三角函数表是第 4 章的内容,几何生成不应该依赖它。
A.2 定点约定
全卷只有四种数值表示,它们之间的转换是显式的,绝不允许隐式发生。
| 表示 | 定义 | 范围 | 用在哪 |
|---|---|---|---|
| Q16.16 | 整数 ÷ 65536 得实际值 | Python int 无限;TS 用 BigInt | 顶点坐标、矩阵、法线、颜色系数、深度归一化 |
| Q6 子像素 | 整数 ÷ 64 得像素坐标 | 画布 320×240 → 最大 320×64 = 20480 | 屏幕空间坐标、边函数 |
| 8 位通道 | 0–255 | 0–255 | 顶点色、背景色、输出像素 |
| 逐像素整数 | numpy int64 / JS Number | 中间量 ≤ 2⁴⁹·⁶ < 2⁵³ | 光栅化的重心权重、深度插值 |
四条舍入铁律
- 降精度一律用算术右移
>>。>>是向下取整,Python 的int与 JS 的BigInt语义完全相同。禁用/与//做降精度——Python 的//是 floor、BigInt 的/是向零截断,负数上差 1。 - 定点除法用显式向零截断的实现(
fdiv),不用语言自带的整除运算符。第 4 章的自检脚本会实测这条:fdiv(-1.0, 3.0) = -21845,而裸//给出-21846。 - 矩阵链必须在
BigInt里算。 Q16.16 矩阵乘的中间量可达 2³⁴ 量级,JS 的Number的>>只对 32 位有效,溢出会静默给出错误结果。逐像素层量级在 2⁵³ 以内,可以用Number+Math.floor。 - 感知类派生量按声明容差断言,不写「逐位相等」。 只有整数类的量(像素缓冲、覆盖掩码、深度缓冲)才允许写「逐字节相同」。
A.3 定点不变量清单
这些不变量在每一节的代码块里被反复断言,它们合起来构成「这份实现是自洽的」这一主张。
| 不变量 | 表述 | 断言形式 |
|---|---|---|
| 权重和守恒 | 重心坐标三权重之和恒等于三角形的边函数面积 | 残差 恒为 0(整数,非容差) |
| 深度单调 | 相机距离增大时,归一化深度单调不减 | 逐对比较,无例外 |
| 深度值域 | 归一化深度落在 [0, ONE] | 上下界断言 |
| 覆盖与面积一致 | 三角形覆盖像素数 ≤ 其屏幕包围盒面积 | 不等式断言 |
| 顶点数守恒 | 网格细分后顶点数 == 解析公式给出值 | 等式断言 |
| 环绕与法线一致 | 屏幕空间 area > 0 的三角形法线朝观察者 | 符号断言 |
| 规格无浮点 | config/ 下任何 JSON 不含 float | 类型扫描,计数恒为 0 |
| 跨语言同摘要 | Python 与 TypeScript 的像素缓冲 sha256 相同 | 字符串相等 |
A.4 两条一致性纪律的判据
| 纪律 | 对象 | 判据 | 允许失败吗 |
|---|---|---|---|
| 整数与定点逐位一致 | S 层 vs P 层 | 像素缓冲 sha256 完全相同 | 不允许。不同即实现有分歧 |
| 浮点按声明容差 | S 层 vs W 层(WebGL2) | 覆盖归属差异 ≤ 20‰、MAE ≤ 2.0、PSNR ≥ 30 dB、显著差异像素 ≤ 1‰ | 允许,但必须在阈值内 |
第二条的三个阈值不是拍脑袋定的:基线场景下实测覆盖归属差异 0.08‰、MAE 0.2848、PSNR 47.79 dB、显著差异像素 0.36‰,阈值取了实测值的两个数量级以上余量,同时又能拒绝一个故意写错的着色器(负例 MAE 20.95、PSNR 19.12 dB)。判据必须配负例——同一个阈值若不拒绝故意写错实现,PASS 就没有意义。
A.5 数据集索引
所有产物落在工作区 lantern/out/,命名规则是 <前缀>_<章号>_<节号>_<名称>.<扩展名>,其中卷一是 cg_raster_。
| 类别 | 文件 | 说明 |
|---|---|---|
| 规格 | config/scene_spec.json | 唯一真源,71 字段 |
| 基线像素缓冲 | out/raster_hollow.rgb | 320×240×3 字节,S 层与 P 层逐字节相同的对象 |
| 基线覆盖掩码 | out/raster_hollow_covered.bin | 每像素 1 字节,用于区分「覆盖归属差异」与「颜色差异」 |
| 基线成片 | out/raster_hollow.png / raster_depth.png / raster_coverage.png | 成片、深度图、覆盖率图 |
| 基线统计 | out/raster_stats.json | 顶点 / 三角形 / 剔除 / 覆盖 / sha256 / 耗时 |
| P 层对照 | out/raster_hollow_ts.rgb / _ts.png / _ts_stats.json | TypeScript 侧产物,用于 sha256 比对 |
| 各章 S 层产物 | out/cg_raster_<章>_<节>_*.{json,csv,png,svg} | 每节的仿真输出与中间缓冲 |
| W 层页面 | web/dist/raster<NN>.html | 单文件 HTML,规格与内核已内联 |
| W 层截图 | out/shots/*.png | Playwright 无头截图,证明浏览器真的渲染过 |
| B 层基准 | out/bench_baseline.json | 参数扰动的基线,软告警不硬失败 |
config/ 下的规格文件同样遵循前缀规则(config/cg_raster_<章>_<节>_*.json),目录里不留 .bak——清点脚本按「前缀 + 两位章号」归章,多余的备份文件会被算进该章并打破金标准摘要。
A.6 定点约定的自检
下面这个脚本是附录里唯一可跑的块。它把规格摊平成字段表,逐条断言 A.2 的舍入铁律与 A.3 的「规格无浮点」不变量,并落盘 CSV。它断言失败即退出非 0——这正是本卷对待约定的一贯态度:约定不是文档里的句子,是可执行的断言。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_appendix_fields.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_appendix_fields.py
"""附录 A 的字段表与定点约定自检:把 scene_spec.json 摊平成表,并逐条断言定点约定。
本脚本是附录 A 唯一的可跑块,它证明两件事:
① 场景规格里**没有一个浮点数**(config/ 禁浮点),且字段可以被摊平成一张表;
② 定点约定的四条铁律在 Python 侧成立,且与 TypeScript 侧 fixed.ts 逐字同义。
产物:out/cg_raster_appendix_fields.csv(字段表)
out/cg_raster_appendix_fixed.json(定点约定自检结果)
"""
import json
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__)))
def flatten(obj, prefix=""):
"""把嵌套的规格摊平成 (路径, 类型, 值) 三元组列表,顺序即字典插入序。"""
rows = []
if isinstance(obj, dict):
for k, v in obj.items():
rows.extend(flatten(v, f"{prefix}.{k}" if prefix else k))
elif isinstance(obj, list):
for i, v in enumerate(obj):
rows.extend(flatten(v, f"{prefix}[{i}]"))
else:
rows.append((prefix, type(obj).__name__, obj))
return rows
def check_fixed_point():
"""四条铁律的自检。任一断言失败即退出非 0。"""
out = {}
# ① 常量的定义
out["ONE"] = cglib.ONE
out["SUB"] = cglib.SUB
out["SUB1"] = cglib.SUB1
assert cglib.ONE == 65536, "ONE 必须是 65536"
assert cglib.SUB == 6 and cglib.SUB1 == 64, "子像素必须是 6 位 / 1 像素 = 64 单位"
# ② 乘法:降精度用算术右移(负数向下取整),1.0 * 1.0 == 1.0
assert cglib.fmul(cglib.ONE, cglib.ONE) == cglib.ONE
assert cglib.fmul(-cglib.ONE, cglib.ONE) == -cglib.ONE
# 0.5 * 0.5 == 0.25(Q16.16 里 0.25 = 16384)
assert cglib.fmul(cglib.ONE // 2, cglib.ONE // 2) == cglib.ONE // 4
out["fmul_ok"] = True
# ③ 除法:向零截断。Python 的 // 是 floor,负数上会给出不同的答案 ——
# 这正是必须用 fdiv 而不是裸 // 的原因。
# 注意例子必须**不整除**:-3.0/2.0 恰好整除,两种除法给出相同结果,看不出差别。
a, b = -cglib.ONE, 3 * cglib.ONE # -1.0 / 3.0
out["fdiv_neg1_over_3"] = cglib.fdiv(a, b)
out["floor_neg1_over_3"] = (a << 16) // b
assert cglib.fdiv(a, b) == -21845, "fdiv(-1.0, 3.0) 应为 -0.33333… 向零截断 = -21845"
assert (a << 16) // b == -21846, "裸 // 给出 -21846(floor),与 fdiv 差 1 —— 这就是必须禁 // 的理由"
# ④ 平方根:fsqrt 的入参与返回值都是 Q16.16,内部走 isqrt(x << 16)
assert cglib.fsqrt(cglib.ONE) == cglib.ONE, "sqrt(1.0) 必须精确等于 1.0"
assert cglib.fsqrt(4 * cglib.ONE) == 2 * cglib.ONE, "sqrt(4.0) 必须精确等于 2.0"
# 非完全平方:floor(sqrt(2) * 65536) = floor(92681.9) = 92681
out["fsqrt_2"] = cglib.fsqrt(2 * cglib.ONE)
assert cglib.fsqrt(2 * cglib.ONE) == 92681, "sqrt(2.0) 向下取整应为 92681"
out["fsqrt_ok"] = True
# ⑤ 屏幕映射:NDC 的 [-1,1] 映到 [0, width*SUB1],1 像素恰好 64 个单位
sx0, _ = cglib.to_screen(-cglib.ONE, 0, 320, 240)
sx1, _ = cglib.to_screen(0, 0, 320, 240)
out["sx_ndc_minus1"] = sx0
out["sx_ndc_zero"] = sx1
assert sx0 == 0, "NDC x = -1 必须映到屏幕 x = 0"
assert sx1 == 320 * 64 // 2, "NDC x = 0 必须映到画布中点 320*64/2"
out["one_pixel_units"] = cglib.SUB1
return out
def main():
spec = cglib.load_spec()
rows = flatten(spec)
# config/ 禁浮点:任何一个 float 都是规格错误
floats = [r for r in rows if r[1] == "float"]
assert not floats, f"config/ 禁浮点,发现 {len(floats)} 个:{floats[:5]}"
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "cg_raster_appendix_fields.csv"), "w", encoding="utf-8") as fh:
fh.write("path,type,value\n")
for path, typ, val in rows:
fh.write(f"{path},{typ},{val}\n")
fixed = check_fixed_point()
with open(os.path.join(out, "cg_raster_appendix_fixed.json"), "w", encoding="utf-8") as fh:
json.dump(fixed, fh, ensure_ascii=False, indent=2)
kinds = {}
for _, typ, _v in rows:
kinds[typ] = kinds.get(typ, 0) + 1
print(f"字段数 : {len(rows)}")
print(f"类型分布 : {kinds}")
print(f"浮点数 : {len(floats)}(config/ 禁浮点)")
print(f"定点约定自检 : 5 项全过")
print(f"fdiv(-1.0,3.0): {fixed['fdiv_neg1_over_3']} 裸 // 给出 {fixed['floor_neg1_over_3']}")
print(f"1 像素 : {fixed['one_pixel_units']} 个子像素单位")
print(f"csv : out/cg_raster_appendix_fields.csv")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出:
字段数 : 71
类型分布 : {'str': 1, 'int': 70}
浮点数 : 0(config/ 禁浮点)
定点约定自检 : 5 项全过
fdiv(-1.0,3.0): -21845 裸 // 给出 -21846
1 像素 : 64 个子像素单位
csv : out/cg_raster_appendix_fields.csv
最后一行值得单独看一眼:fdiv(-1.0, 3.0) 给出 -21845,而同一表达式用裸 // 会给出 -21846。差的这 1,在单个像素上不可见,但在几百万个顶点上累积,会让两种语言的像素缓冲 sha256 分道扬镳——这正是本卷坚持「降精度一律用算术右移、定点除法必须显式向零截断」的原因。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。