附录 A 场景规格字段表、定点约定与数据集索引

《计算机图形学》卷一附录 A:scene_spec.json 的 71 个字段全表(相机、方向光、点光源、地形、球体、渲染参数,全部为整数)、Q16.16 与 Q6 子像素的定点约定、四条舍入铁律、定点不变量清单、两条一致性纪律的判据表,以及全书数据集索引与一个可跑的定点约定自检脚本。

本附录是全卷的查阅表:规格有哪些字段、每个字段是什么量纲、定点数怎么约定、哪些不变量必须成立、各章的产物放在哪。正文里所有断言都建立在这几张表上,遇到「这个数是多少、为什么是这个数」的问题,先查这里。

A.1 场景规格字段全表

config/scene_spec.json 共 71 个字段,类型分布是 70 个整数 + 1 个字符串(name),浮点数为 0。这不是风格偏好,而是硬约束:只要出现一个浮点,Python 与 TypeScript 的逐字节一致就失去保证。

量纲后缀的含义:Q16 = Q16.16 定点(值 ÷ 65536 得实际数),Q8 = 8 位通道(0–255),无后缀 = 纯整数。

渲染参数与全局

字段值量纲说明
namelantern-hollowstr场景标识
seed20261015int噪声与场景生成的种子
width / height320 / 240int画布尺寸(像素)
subpixelBits6int子像素位数,1 像素 = 2⁶ = 64 单位
depthBits16int深度缓冲的位宽
backgroundQ8[12,14,20]Q8背景色,三通道
ambientQ1613107Q16环境光项(≈0.2000)

相机(camera)

字段值量纲说明
eyeQ16[0, 393216, 1572864]Q16相机位置(世界 0 / 6 / 24)
targetQ16[0, 131072, 0]Q16注视点(世界 0 / 2 / 0)
upQ16[0, 65536, 0]Q16上方向
tanHalfFovYQ1637837Q16半视场角正切(≈0.5774,即 60° 竖直视场)
nearQ16 / farQ1632768 / 4194304Q16近远平面(0.5 / 64)

光照(directionalLight / pointLight)

字段值量纲说明
directionalLight.dirQ16[-37837,37837,37837]Q16方向光朝向(已归一化)
directionalLight.colorQ8[255,236,200]Q8方向光颜色(暖白)
directionalLight.intensityQ1658982Q16方向光强度(≈0.9000)
pointLight.posQ16[327680,524288,327680]Q16点光源位置(世界 5 / 8 / 5)
pointLight.colorQ8[255,170,90]Q8点光源颜色(橙)
pointLight.intensityQ1665536Q16点光源强度(1.0)
pointLight.rangeQ161048576Q16点光源作用半径(16.0)

卷一只用方向光与环境光。 点光源字段在规格里就位、可被校验,但卷一的着色器不消费它——点光源、衰减与多光源求值是卷二的内容。这样安排是为了让规格一次成型:卷二不会改规格,只会多消费一个字段。

地形(terrain)

字段值量纲说明
gridN16int高度场格点数(16×16 顶点)
extentQ161441792Q16地形边长(22.0 世界单位)
heightScaleQ16196608Q16高度振幅(3.0)
noiseSeed1337int高度噪声种子
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
radiusQ1613107298304163840Q16
albedoQ8[220,90,70][90,180,220][200,200,190]Q8
subdiv222int

球体用八面体细分生成(subdiv=2 时每个球 128 个三角形),刻意避开三角函数——定点三角函数表是第 4 章的内容,几何生成不应该依赖它。

A.2 定点约定

全卷只有四种数值表示,它们之间的转换是显式的,绝不允许隐式发生。

表示定义范围用在哪
Q16.16整数 ÷ 65536 得实际值Python int 无限;TS 用 BigInt顶点坐标、矩阵、法线、颜色系数、深度归一化
Q6 子像素整数 ÷ 64 得像素坐标画布 320×240 → 最大 320×64 = 20480屏幕空间坐标、边函数
8 位通道0–2550–255顶点色、背景色、输出像素
逐像素整数numpy int64 / JS Number中间量 ≤ 2⁴⁹·⁶ < 2⁵³光栅化的重心权重、深度插值

四条舍入铁律

  1. 降精度一律用算术右移 >>。 >> 是向下取整,Python 的 int 与 JS 的 BigInt 语义完全相同。禁用 / 与 // 做降精度——Python 的 // 是 floor、BigInt 的 / 是向零截断,负数上差 1。
  2. 定点除法用显式向零截断的实现(fdiv),不用语言自带的整除运算符。第 4 章的自检脚本会实测这条:fdiv(-1.0, 3.0) = -21845,而裸 // 给出 -21846。
  3. 矩阵链必须在 BigInt 里算。 Q16.16 矩阵乘的中间量可达 2³⁴ 量级,JS 的 Number 的 >> 只对 32 位有效,溢出会静默给出错误结果。逐像素层量级在 2⁵³ 以内,可以用 Number + Math.floor。
  4. 感知类派生量按声明容差断言,不写「逐位相等」。 只有整数类的量(像素缓冲、覆盖掩码、深度缓冲)才允许写「逐字节相同」。

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.rgb320×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.jsonTypeScript 侧产物,用于 sha256 比对
各章 S 层产物out/cg_raster_<章>_<节>_*.{json,csv,png,svg}每节的仿真输出与中间缓冲
W 层页面web/dist/raster<NN>.html单文件 HTML,规格与内核已内联
W 层截图out/shots/*.pngPlaywright 无头截图,证明浏览器真的渲染过
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 分道扬镳——这正是本卷坚持「降精度一律用算术右移、定点除法必须显式向零截断」的原因。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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