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)。
量纲决定了取值范围
字段名里的后缀不是装饰,它标明了量纲,量纲又直接决定取值范围:
| 后缀 | 含义 | 取值约束 |
|---|---|---|
Q16 | Q16.16 定点标量 | |v| ≤ 2^31 |
Q16 + 世界量 | 世界坐标或世界尺寸 | |v| ≤ 2^24(约 ±256 世界单位) |
Q8 | 8 位颜色通道 | [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 最小渲染器的骨架 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。