1.2 场景规格的字段

config/scene_spec.json 有 31 个必填字段,分布在渲染参数、相机、两盏光源、地形与球体五组里。本节逐组讲清每个字段的量纲与取值范围,给出纯 Python 的 schema 校验脚本,断言所有数值字段都是整数、Q16.16 落在安全区间内,并用一份被污染的规格证明这套判据会拒绝它。

1.2 场景规格的字段

上一节跑通了一条从 JSON 到 PNG 的链路,这一节把那份 JSON 拆开看——它凭什么能同时驱动 Python、TypeScript 和浏览器里的三套实现?先划一次界:站内的 /graphics-pipeline-theory/ 讲的是现代可编程管线的阶段划分,哪个阶段做什么、API 怎么暴露它们;本节关心的不是管线本身,而是喂给管线的输入长什么样——一份规格里的每个字段叫什么、是什么量纲、取值范围多大、哪些约束是硬的。换句话说,那篇讲「管线怎么跑」,本节讲「喂进去的东西得满足什么条件」。

字段讲清楚之后,它就不再是「文档里的约定」,而是可执行的断言。一份规格如果字段缺失、类型不对、量级越界,应该在校验阶段就失败,而不是等到渲染出怪图才回头猜。这就是本节的产物:一个 schema,加一个不依赖任何第三方库的校验器。

五组字段

规格分成五组,每组回答一个问题:

  • 渲染参数(顶层)——width / height 定画布,subpixelBits / depthBits 定精度,backgroundQ8 定底色,ambientQ16 定环境项。它们不描述场景,而是描述「怎么画这个场景」。
  • camera——眼睛在哪(eyeQ16)、看哪(targetQ16)、头顶朝哪(upQ16)、视锥多宽(tanHalfFovYQ16)、近远平面在哪(nearQ16 / farQ16)。六个数定死一个针孔相机。
  • directionalLight——方向光的朝向(dirQ16)、颜色(colorQ8)、强度(intensityQ16)。方向光是平行光,所以没有位置。
  • pointLight——点光源的位置(posQ16)、颜色(colorQ8)、强度(intensityQ16)、作用半径(rangeQ16)。比方向光多一个位置,多一个衰减半径。
  • terrain——地形网格的分辨率(gridN)、覆盖范围(extentQ16)、起伏幅度(heightScaleQ16)、噪声种子(noiseSeed),以及低处与高处的两种底色(albedoQ8 / albedoHighQ8)。
  • spheres[]——材质球数组,每个元素是球心(centerQ16)、半径(radiusQ16)、底色(albedoQ8)、细分级数(subdiv)。

量纲决定了取值范围

字段名里的后缀不是装饰,它标明了量纲,量纲又直接决定取值范围:

后缀含义取值约束
Q16Q16.16 定点标量|v| ≤ 2^31
Q16 + 世界量世界坐标或世界尺寸|v| ≤ 2^24(约 ±256 世界单位)
Q88 位颜色通道[0, 255]
比例量强度、环境项、视锥半角正切落在 [0, 65536] 或 |v| ≤ 65536

两条界的来历值得说清楚。2^31 是单次定点乘法的安全上界:fmul(a, b) 算的是 (a * b) >> 16,|a| 与 |b| 都小于 2^31 时中间积不超过 2^62,稳稳落在有符号 64 位整数里。2^24 则是给世界坐标单独收紧的一条:场景里最远的量是远平面 farQ16 = 4194304 = 2^22,取 2^24 留了四倍余量,同时保证世界坐标的整数部分不会大到让 Q6 屏幕映射失去子像素精度。

比例量的约束最严,因为它们参与的是乘性运算。顶点光照里 k = ambient + fmul(ndl, intensity),ambient 和 intensity 都必须落在 [0, ONE],否则 k 会超过 1 被截断,颜色就不对了。dirQ16 和 upQ16 是方向向量,要求每个分量的绝对值不超过 ONE——归一化向量的分量本来就该在这个范围内。

还有两条不是数值范围的硬约束:subpixelBits 必须等于 6、depthBits 必须等于 16。它们不是「可调参数」,而是整个一致性体系的地基——屏幕坐标是 Q6,深度是 Q16.16,改了它们,S 层与 P 层的逐字节一致立刻失效。把常量写进 schema,是为了让「有人顺手改了一下」这件事在第一时间被拦住。

D 档产物:可执行的 schema

下面这份 schema 是本节的核心产物。它声明了 31 条必填路径、每条路径的 kind、四个计数类字段的区间、两个常量字段的期望值,以及所有 kind 共享的三条边界。

{
  "schema": "lantern-scene-spec",
  "version": 1,
  "one": 65536,
  "bounds": {
    "q16AbsMax": 2147483648,
    "worldAbsMax": 16777216,
    "colorMax": 255
  },
  "ranges": {
    "width": [1, 8192],
    "height": [1, 8192],
    "terrain.gridN": [1, 256],
    "spheres[].subdiv": [0, 4]
  },
  "constants": {
    "subpixelBits": 6,
    "depthBits": 16
  },
  "required": {
    "name": "str",
    "seed": "int",
    "width": "int",
    "height": "int",
    "subpixelBits": "int",
    "depthBits": "int",
    "backgroundQ8": "q8",
    "ambientQ16": "q16n",
    "camera.eyeQ16": "q16w",
    "camera.targetQ16": "q16w",
    "camera.upQ16": "q16s",
    "camera.tanHalfFovYQ16": "q16p",
    "camera.nearQ16": "q16w+",
    "camera.farQ16": "q16w+",
    "directionalLight.dirQ16": "q16s",
    "directionalLight.colorQ8": "q8",
    "directionalLight.intensityQ16": "q16n",
    "pointLight.posQ16": "q16w",
    "pointLight.colorQ8": "q8",
    "pointLight.intensityQ16": "q16n",
    "pointLight.rangeQ16": "q16w+",
    "terrain.gridN": "int",
    "terrain.extentQ16": "q16w+",
    "terrain.heightScaleQ16": "q16w",
    "terrain.noiseSeed": "int",
    "terrain.albedoQ8": "q8",
    "terrain.albedoHighQ8": "q8",
    "spheres[].centerQ16": "q16w",
    "spheres[].radiusQ16": "q16w+",
    "spheres[].albedoQ8": "q8",
    "spheres[].subdiv": "int"
  }
}

路径用点号分层,段尾加 [] 表示「展开数组的每个元素」。spheres[].radiusQ16 这一条会作用到三个球上,所以 31 条路径实际覆盖了比 31 更多的取值点。kind 用的是紧凑代号:q16w 是「Q16.16 世界量」,q16w+ 是「正的世界量」,q16n 是「非负比例」,q16p 是「正比例」,q16s 是「有符号单位分量」,q8 是颜色通道。

S 档产物:校验器

schema 是声明,还得有执行者。下面这个校验器只用标准库(csv / json / os / sys),不引入 jsonschema,因为它要做的事比通用校验器更窄也更硬——它必须能断言「所有数值字段都是整数」,而通用 JSON Schema 的类型系统对 Python 的 bool 是 int 子类这件事毫无防备:True 会被当成 1 悄悄放过。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_01_2_validate_spec.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_01_2_validate_spec.py
"""1.2 场景规格的字段校验:纯 Python,不引入 jsonschema。

读 config/cg_raster_01_2_field_schema.json 与 config/scene_spec.json,断言:
  1. 规格里所有数值叶子都是整数(bool 是 int 的子类,必须显式排除)
  2. schema.required 的每条路径都能取到值(必填字段齐全)
  3. 每个字段落在它 kind 声明的安全范围内(Q16.16 / Q8)
  4. 计数类字段落在 ranges 区间内,常量字段等于 constants

产物:out/cg_raster_01_2_field_table.csv(字段表)+ stdout 报告。
退出码:0 = 全过,1 = 有 FAIL。
"""

import csv
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def load_json(path):
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def resolve(spec, path):
    """按点分路径取值;段尾带 [] 表示展开数组的每个元素。返回 (values, missing)。"""
    values = [spec]
    for seg in path.split("."):
        nxt = []
        for node in values:
            if seg.endswith("[]"):
                arr = node.get(seg[:-2]) if isinstance(node, dict) else None
                if isinstance(arr, list):
                    nxt.extend(arr)
            elif isinstance(node, dict) and seg in node:
                nxt.append(node[seg])
        values = nxt
    return values, len(values) == 0


def is_int(v):
    """整数判据:bool 是 int 的子类,必须排除,否则 True 会被当成 1。"""
    return isinstance(v, int) and not isinstance(v, bool)


def check_scalar(v, kind, bounds, one):
    """校验单个标量对某 kind 是否合规,返回 (ok, 取值范围说明)。"""
    if kind == "str":
        return (isinstance(v, str) and v != ""), "非空字符串"
    if not is_int(v):
        return False, f"{kind} 要求整数,实为 {type(v).__name__}"
    if kind == "int":
        return True, "任意整数"
    m, w, c = bounds["q16AbsMax"], bounds["worldAbsMax"], bounds["colorMax"]
    lo, hi, note = {
        "q8": (0, c, f"[0, {c}]"),
        "q16": (-m, m, f"|v| <= {m}"),
        "q16w": (-w, w, f"|v| <= {w}"),
        "q16w+": (1, w, f"0 < v <= {w}"),
        "q16n": (0, one, f"[0, {one}]"),
        "q16p": (1, one, f"(0, {one}]"),
        "q16s": (-one, one, f"|v| <= {one}"),
    }[kind]
    return lo <= v <= hi, note


def walk_numbers(node, path=""):
    """递归枚举规格里的所有数值叶子(含 bool / float,供整数性检查用)。"""
    out = []
    if isinstance(node, dict):
        for k, v in node.items():
            out += walk_numbers(v, f"{path}.{k}" if path else k)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            out += walk_numbers(v, f"{path}[{i}]")
    elif isinstance(node, (bool, int, float)):
        out.append((path, node))
    return out


def main(argv):
    # 可选位置参数:换成另一份规格文件(负例测试用),schema 不变
    spec_path = argv[1] if len(argv) > 1 else os.path.join(ROOT, "config", "scene_spec.json")
    schema = load_json(os.path.join(ROOT, "config", "cg_raster_01_2_field_schema.json"))
    spec = load_json(spec_path)
    bounds, one = schema["bounds"], schema["one"]

    errors, rows = [], []
    leaves = walk_numbers(spec)

    # 1) 数值叶子必须是整数(bool / float 一律拒绝)
    for path, v in leaves:
        if not is_int(v):
            errors.append(f"{path} = {v!r} 不是整数({type(v).__name__})")

    # 2) + 3) 必填齐全,且每个字段落在 kind 声明的范围内
    for path, kind in schema["required"].items():
        values, missing = resolve(spec, path)
        if missing:
            errors.append(f"缺必填字段 {path}")
            continue
        note = ""
        for v in values:
            for c in (v if isinstance(v, list) else [v]):
                ok, note = check_scalar(c, kind, bounds, one)
                if not ok:
                    errors.append(f"{path} = {c!r} 违反 {kind}(要求 {note})")
        rows.append((path, kind, str(values[0] if len(values) == 1 else values), note))

    # 4) 计数类字段的显式区间
    for path, (lo, hi) in schema["ranges"].items():
        for v in resolve(spec, path)[0]:
            if not is_int(v) or not (lo <= v <= hi):
                errors.append(f"{path} = {v!r} 越界 [{lo}, {hi}]")

    # 5) 常量字段
    for path, want in schema["constants"].items():
        if resolve(spec, path)[0] != [want]:
            errors.append(f"{path} 不等于常量 {want}")

    out_dir = os.path.join(ROOT, "out")
    os.makedirs(out_dir, exist_ok=True)
    with open(os.path.join(out_dir, "cg_raster_01_2_field_table.csv"),
              "w", encoding="utf-8", newline="") as fh:
        wr = csv.writer(fh)
        wr.writerow(["path", "kind", "value", "bound"])
        wr.writerows(rows)

    print(f"schema         : {schema['schema']} v{schema['version']}")
    print(f"spec           : {spec['name']}")
    print(f"numeric leaves : {len(leaves)} (all integers: {all(is_int(v) for _, v in leaves)})")
    print(f"required paths : {len(schema['required'])} checked, {len(rows)} in table")
    print("field table    : out/cg_raster_01_2_field_table.csv")
    if errors:
        for e in errors:
            print(f"FAIL {e}")
        print(f"== FAIL {len(errors)}")
        return 1
    print("== OK 0 errors")
    return 0


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

跑一次:

# snippet: 本机实跑命令(工作区为 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_raster_01_2_validate_spec.py
schema         : lantern-scene-spec v1
spec           : lantern-hollow
numeric leaves : 70 (all integers: True)
required paths : 31 checked, 31 in table
field table    : out/cg_raster_01_2_field_table.csv
== OK 0 errors

规格里一共有 70 个数值叶子(spheres[] 展开之后算的),全部是整数;31 条必填路径全部取到值,写进了 out/cg_raster_01_2_field_table.csv,每行是「路径、kind、实际值、该 kind 的取值范围」,可以直接当字段表查。

判据必须能被负例拒绝

一份「全部通过」的报告本身说明不了什么——它可能只是判据太松。所以按本卷的惯例,每个判据都要配一个负例,而且必须证明它真的会被拒绝。

把规格改坏三处:width 从 320 改成 320.5(浮点混入)、camera.nearQ16 改成 0(正的量变成零)、第一个球的 subdiv 从 2 改成 9(越出 [0, 4])。同一份 schema、同一个校验器,只是把规格路径换成被污染的那份:

# snippet: 把规格换成被污染的那份,schema 不变
/opt/homebrew/bin/python3 sim/cg_raster_01_2_validate_spec.py /tmp/cg_raster_01_2_bad_spec.json
schema         : lantern-scene-spec v1
spec           : lantern-hollow
numeric leaves : 70 (all integers: False)
required paths : 31 checked, 31 in table
field table    : out/cg_raster_01_2_field_table.csv
FAIL width = 320.5 不是整数(float)
FAIL width = 320.5 违反 int(要求 int 要求整数,实为 float)
FAIL camera.nearQ16 = 0 违反 q16w+(要求 0 < v <= 16777216)
FAIL width = 320.5 越界 [1, 8192]
FAIL spheres[].subdiv = 9 越界 [0, 4]
== FAIL 5

五条 FAIL,退出码 1。三处污染各自触发了它该触发的检查:浮点被整数性检查抓住,零值被 q16w+ 的严格下界抓住,越界被区间检查抓住。而 width 触发了三条——因为它既不是整数、又违反 int、还越界——这恰好说明几条检查是互相独立的,不是同一条判断换了几种说法。

这里有一条方法论值得记住:判据不被负例拒绝,则 PASS 不可信。 后面第 12 章、第 13 章讲 W 层的容差比对时,同一条纪律会以更严格的形式再出现一次——那里的负例不是「字段类型错了」,而是「片元颜色被故意乘以 0.5」,判据必须照样把它拦下来。

到这里,规格的每个字段都有了明确的名字、量纲和边界,而且这些边界是可执行的。下一节把 1.1 的脚本重构成一个真正的骨架:四段各司其职、各有签名,然后把同一份规格喂进去,渲染出《烛影山谷》的第一帧。

阅读导航:上一节:1.1 从场景到像素 · 下一节:1.3 最小渲染器的骨架 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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