10.3 程序化场景的生成

程序化场景要能当资产用,靠的不是看起来像,而是可断言:同一 seed 必须给出同一场景。本节把 fBm 位移与噪声分档材质接进既有 scene_spec,基线帧 sha256 逐字节锚定 213b4130…,程序化场景的几何 digest 与帧 sha256 在同 seed 重跑下不变、换 seed 必变。

10.3 程序化场景的生成

站内 /graphics-terrain-large-world/ 那篇的程序化地形最终要交给 GPU 按区块流式加载,判据是帧时间和显存占用。本节的目标不同:把 10.1 的噪声、10.2 的 fBm 接进卷三既有的场景规格,做出一个能被 sha256 钉住的程序化场景。判据不是「像不像」,而是同一 seed → 同一场景 → 同一 sha256。

为什么先做基线锚定

在改造场景之前,脚本先干一件看起来多余的事:用 config/scene_spec.json 原样渲染一遍,断言帧的 sha256 逐字节等于卷三基线 213b4130…。

这一步不是仪式。程序化改造最容易出的错不是「新场景不好看」,而是悄悄改了老场景——比如位移量在 displacementQ16 = 0 时没退化成恒等、法线重算改了没位移的地形、或者球体被顺手换掉了。有了这条锚定,任何「改坏了」都会立刻在第一步暴露,而不是等到最后对比两张图时才发现。

实测这一步的计数也正好对上第 4 章的基线:主光线 43250 + 点光源光线 33961 = 77211 条阴影光线,遮挡 839 + 518 = 1357 次。计数一致说明跑的是同一条链路。

位移:几何与光照必须用同一份高度场

地形顶点的位移是 height(x, z) = terrain_height(x, z) + fbm(x, z) × displacementQ16,其中 fbm(x, z) ∈ [0, ONE)。位移幅取 32768,即 0.5 个世界单位。

关键在于法线:顶点移动之后,法线必须按位移后的高度场重算,用中心差分:

n = normalize( (−∂h/∂x, 2·NSTEP, −∂h/∂z) )

NSTEP = 2^17(2.0 世界单位),与 cgshade.terrain_normal 用的是同一个步长。如果偷懒继续用原高度场的法线,几何和光照就会不一致:地形凹凸了,明暗却还是平的——这种错在单张图里很难看出来,但在位移量增大时会迅速露馅。

球体原样保留(顶点、法线、反照率都不动)。这是刻意的隔离变量:如果场景整体变了,你就分不清是地形的改动还是球体的改动造成的。

材质:按噪声分档,再加确定性抖动

材质不另设一套噪声,直接复用同一个 fBm:取三角形质心处的 fBm 值,量化成 4 档,从调色板里选一组基色,再用底座的 SplitMix64(cgtrace.rng_below)给每个通道加 ±12 的抖动。

抖动流的起始状态由 rngSeed XOR 第 0 层 octave 种子 派生。这个细节很重要:如果起始状态是一个常量,那么换 seed 时地形会变但抖动序列不会变——「同一 seed 同一场景」就漏掉了最后一批随机数。加上这一项之后,换 seed 连每片三角形的抖动都一起换。

{
  "name": "lantern-procedural-scene",
  "seed": 20261016,
  "altSeed": 20261017,
  "octaves": 4,
  "gainQ16": 32768,
  "latticeShift": 18,
  "displacementQ16": 32768,
  "materialBands": 4,
  "jitterQ8": 12,
  "rngSeed": 424242,
  "paletteQ8": [
    [72, 82, 66],
    [112, 104, 84],
    [158, 152, 132],
    [210, 212, 216]
  ],
  "baselineSha256": "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71"
}

paletteQ8 四档从深苔绿到雪白,对应低地到高地;baselineSha256 写进配置,让锚定值成为配置的一部分而不是脚本里的魔法常量。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_10_3_procedural_scene.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_10_3_procedural_scene.py
"""10.3《程序化场景的生成》:把噪声驱动的位移与材质接进既有场景规格。

程序化场景要能当资产用,靠的不是「看起来像」,而是**可断言**:同一 seed 必须给出同一
场景(几何字节 + 渲染字节都相同),换 seed 必须给出另一个场景。本节量四件事:

  1. **基线锚定**:直接用 `config/scene_spec.json` 渲染一遍,sha256 必须逐字节等于卷三
     基线 `213b4130…`。程序化改造不能把基线搞坏。
  2. **位移**:地形顶点高度加上 `fbm(x, z) × 位移幅`;法线按**位移后高度场**的中心差分
     重算(否则光照与几何不一致)。球体原样保留,用来隔离变量。
  3. **材质**:地形三角形按质心处的 fBm 值分档选调色板,再用底座 SplitMix64
     (`cgtrace.rng_below`)给每通道加确定性抖动。
  4. **确定性判据**:同 seed 跑两次,**场景字节 digest 与帧 sha256 都必须相同**;
     换 seed 则两者都必须变。这是「可复现」在本书里的具体含义。

产物:out/cg_trace_10_3_procedural.json、out/cg_trace_10_3_scene.csv、
      out/cg_trace_10_3_{baseline,procedural}.png
"""

import hashlib
import json
import os
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib  # noqa: E402
import cgtrace  # noqa: E402
import cg_trace_04_light as light  # noqa: E402
from cg_trace_10_noise import fbm, octave_seeds  # noqa: E402

fmul = cglib.fmul
CONFIG = os.path.join(ROOT, "config", "cg_trace_10_3_procedural.json")
OUT = os.path.join(ROOT, "out")
NSTEP = 1 << 17          # 法线中心差分步长 2.0 世界单位(与 cgshade.terrain_normal 同)


def scene_digest(tris):
    """场景字节 digest:21 个整数逐条按 int32 小端编码——「同一场景」的判据对象。"""
    buf = bytearray()
    for t in tris:
        for v in t:
            buf += (v & 0xFFFFFFFF).to_bytes(4, "little")
    return hashlib.sha256(bytes(buf)).hexdigest()


def build_procedural(spec, cfg, seeds):
    """程序化场景:地形位移 + 噪声分档材质,球体原样保留(隔离变量)。

    抖动流的起始状态由 `rngSeed XOR 第 0 层 octave 种子` 派生,故换 seed 时连每三角形的
    抖动序列也一起变——「同一 seed 同一场景」覆盖到最后一个随机数。
    """
    terr = spec["terrain"]
    hs, ext, gn = terr["heightScaleQ16"], terr["extentQ16"], terr["gridN"]
    disp, gain, shift = cfg["displacementQ16"], cfg["gainQ16"], cfg["latticeShift"]
    pal, bands, jit = cfg["paletteQ8"], cfg["materialBands"], cfg["jitterQ8"]
    spec_seed = terr["noiseSeed"]

    def height(x, z):
        return cglib.terrain_height(x, z, spec_seed, hs) + fmul(disp, fbm(x, z, seeds, gain, shift))

    def normal(x, z):
        hx = height(x + NSTEP, z) - height(x - NSTEP, z)
        hz = height(x, z + NSTEP) - height(x, z - NSTEP)
        return cglib.v_norm((-hx, 2 * NSTEP, -hz))

    tv, tt = cglib.build_terrain(gn, ext, hs, spec_seed)
    tv = [(x, height(x, z), z) for (x, _y, z) in tv]
    st = (cfg["rngSeed"] ^ seeds[0]) & cgtrace.RNG_MASK
    hist, rows, tris = [0] * bands, [], []
    for a, b, c in tt:
        cx = (tv[a][0] + tv[b][0] + tv[c][0]) // 3
        cz = (tv[a][2] + tv[b][2] + tv[c][2]) // 3
        # 材质分档:把质心处的 fBm 值([0, ONE))量化成 bands 档(分子非负,右移即整除)
        band = min(bands - 1, (fbm(cx, cz, seeds, gain, shift) * bands) >> 16)
        st, j = cgtrace.rng_below(st, 2 * jit + 1)
        alb = tuple(max(0, min(255, pal[band][i] + j - jit)) for i in range(3))
        hist[band] += 1
        rows.append([a, band, j - jit, alb[0], alb[1], alb[2]])
        tris.append(cgtrace._tri_record(tv[a], tv[b], tv[c],
                                        normal(tv[a][0], tv[a][2]), normal(tv[b][0], tv[b][2]),
                                        normal(tv[c][0], tv[c][2]), alb))
    for sp in spec["spheres"]:
        sv, stt = cglib.build_sphere(sp["centerQ16"], sp["radiusQ16"], sp["subdiv"])
        ctr, alb = tuple(sp["centerQ16"]), tuple(sp["albedoQ8"])
        sn = [cglib.v_norm(cglib.v_sub(v, ctr)) for v in sv]
        for a, b, c in stt:
            tris.append(cgtrace._tri_record(sv[a], sv[b], sv[c], sn[a], sn[b], sn[c], alb))
    return tris, hist, rows, st


def render(spec, tris, keys):
    """用第 4 章的直接光照求值器渲染一份给定的三角形数组。"""
    nodes, order = cgtrace.build_bvh(tris)
    ctx = {"stats": {k: 0 for k in keys}}

    def factory(c):
        c["tris"] = tris
        c["bvh"] = (nodes, order)
        return light.build_direct(spec, nodes, order, tris, c["tmax"],
                                  c["stats"], c["bvh_stats"])

    t0 = time.time()
    color, stats = cgtrace.render_trace(spec, spec["width"], spec["height"], 1, factory, ctx)
    return color, stats, ctx["stats"], int((time.time() - t0) * 1000)


def main():
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    spec = cglib.load_spec()
    os.makedirs(OUT, exist_ok=True)
    keys = ["dir_rays", "dir_hits", "pt_rays", "pt_hits"]

    # ---- 1. 基线锚定 ----
    base_tris, _ = cgtrace.build_scene_tris(spec)
    b_col, _bs, b_cnt, b_ms = render(spec, base_tris, keys)
    b_sha = cgtrace.digest(b_col)
    assert b_sha == cfg["baselineSha256"], f"基线锚定失败:{b_sha}"

    # ---- 2/3. 程序化场景(seed A 跑两次) ----
    seeds_a = octave_seeds(cfg["seed"], cfg["octaves"])
    tris_a, hist_a, rows_a, rng_a = build_procedural(spec, cfg, seeds_a)
    a_col, _as, a_cnt, a_ms = render(spec, tris_a, keys)
    a_sha = cgtrace.digest(a_col)
    tris_a2, _, _, _ = build_procedural(spec, cfg, seeds_a)
    rep_col, _, _, _ = render(spec, tris_a2, keys)

    # ---- 换 seed:场景与帧都必须变 ----
    seeds_b = octave_seeds(cfg["altSeed"], cfg["octaves"])
    tris_b, hist_b, _, rng_b = build_procedural(spec, cfg, seeds_b)
    b2_col, _, _, _ = render(spec, tris_b, keys)

    # ---- 位移统计(逐顶点:位移幅 × fBm 值) ----
    base_v, _ = cglib.build_terrain(spec["terrain"]["gridN"], spec["terrain"]["extentQ16"],
                                    spec["terrain"]["heightScaleQ16"], spec["terrain"]["noiseSeed"])
    dy = [fmul(cfg["displacementQ16"], fbm(x, z, seeds_a, cfg["gainQ16"], cfg["latticeShift"]))
          for (x, _y, z) in base_v]
    cglib.write_csv(os.path.join(OUT, "cg_trace_10_3_scene.csv"),
                    ["triIndex", "materialBand", "jitter", "r", "g", "b"], rows_a)
    cglib.save_png(os.path.join(OUT, "cg_trace_10_3_baseline.png"), b_col)
    cglib.save_png(os.path.join(OUT, "cg_trace_10_3_procedural.png"), a_col)

    sha_a2, dig_a, dig_a2, sha_b, dig_b = (cgtrace.digest(rep_col), scene_digest(tris_a),
                                           scene_digest(tris_a2), cgtrace.digest(b2_col),
                                           scene_digest(tris_b))
    report = {
        "canvas": [spec["width"], spec["height"]],
        "baseline": {"sha256": b_sha, "triangles": len(base_tris), "dirRays": b_cnt["dir_rays"],
                     "dirHits": b_cnt["dir_hits"], "ptRays": b_cnt["pt_rays"],
                     "ptHits": b_cnt["pt_hits"], "ms": b_ms},
        "procedural": {"seed": cfg["seed"], "octaveSeeds": seeds_a, "sha256": a_sha,
                       "repeatSha256": sha_a2, "sceneDigest": dig_a, "repeatSceneDigest": dig_a2,
                       "triangles": len(tris_a), "materialHistogram": hist_a,
                       "dirRays": a_cnt["dir_rays"], "dirHits": a_cnt["dir_hits"],
                       "ptRays": a_cnt["pt_rays"], "ptHits": a_cnt["pt_hits"], "ms": a_ms},
        "altSeed": {"seed": cfg["altSeed"], "octaveSeeds": seeds_b, "sha256": sha_b,
                    "sceneDigest": dig_b, "materialHistogram": hist_b},
        "displacement": {"scaleQ16": cfg["displacementQ16"], "maxQ16": max(dy),
                         "maeQ16": round(sum(dy) / len(dy), 4),
                         "changedVertices": sum(1 for d in dy if d != 0)},
        "rng": {"seed": cfg["rngSeed"], "draws": len(rows_a),
                "finalStateHex": hex(rng_a), "altFinalStateHex": hex(rng_b)},
        "difference": {"mae": round(cgtrace.mae(b_col, a_col), 4),
                       "psnr": round(cgtrace.psnr(b_col, a_col), 4),
                       "maeAlt": round(cgtrace.mae(a_col, b2_col), 4)},
        "determinismOk": (a_sha == sha_a2 and dig_a == dig_a2),
        "seedSensitivityOk": (a_sha != sha_b and dig_a != dig_b),
    }
    with open(os.path.join(OUT, "cg_trace_10_3_procedural.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print("== 10.3 程序化场景的生成 ==")
    print(f"基线 sha256       : {b_sha} -> 锚定 {b_sha == cfg['baselineSha256']}")
    print(f"程序化 seed {cfg['seed']} : sha256 {a_sha[:24]}…")
    print(f"同 seed 重跑      : {sha_a2[:24]}… -> {a_sha == sha_a2}")
    print(f"场景 digest       : {dig_a[:24]}… -> 重跑一致 {dig_a == dig_a2}")
    print(f"换 seed {cfg['altSeed']} : sha256 {sha_b[:24]}… -> 与 seed A 不同 {a_sha != sha_b}")
    print(f"材质直方图        : {hist_a}(档位 0..{cfg['materialBands'] - 1})")
    print(f"位移              : max {max(dy)} MAE {report['displacement']['maeQ16']} Q16.16"
          f"({report['displacement']['changedVertices']} 个顶点被改动)")
    print(f"基线 vs 程序化    : MAE {report['difference']['mae']} PSNR {report['difference']['psnr']} dB")
    print(f"确定性 / seed 敏感: {report['determinismOk']} / {report['seedSensitivityOk']}")
    print(f"耗时              : 基线 {b_ms} ms / 程序化 {a_ms} ms")
    return 0


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

render() 直接复用第 4 章的 cg_trace_04_light.build_direct,它接受显式的三角形数组而不是从场景重建——这正是 10.3 能拿它渲染一份程序化几何的原因。渲染链路没有为本章新写一行。

# snippet: 本机实跑(工作区 lantern/)
/opt/homebrew/bin/python3 sim/cg_trace_10_3_procedural_scene.py
== 10.3 程序化场景的生成 ==
基线 sha256       : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71 -> 锚定 True
程序化 seed 20261016 : sha256 559c64acf99623a3730ce03b…
同 seed 重跑      : 559c64acf99623a3730ce03b… -> True
场景 digest       : 8aaf755e33b12f043b893d04… -> 重跑一致 True
换 seed 20261017 : sha256 ab3e01854687e55454bc3869… -> 与 seed A 不同 True
材质直方图        : [11, 229, 251, 21](档位 0..3)
位移              : max 27479 MAE 16793.2111 Q16.16(289 个顶点被改动)
基线 vs 程序化    : MAE 10.8484 PSNR 27.4235 dB
确定性 / seed 敏感: True / True
耗时              : 基线 6829 ms / 程序化 6599 ms

这些数字怎么读

基线锚定 True 是整节的支点:程序化改造没有动老场景,且这条链路的 sha256 与第 4 章完全一致。

位移改动了全部 289 个顶点,最大 27479 Q16.16(约 0.419 世界单位),MAE 16793(约 0.256)。位移幅配置是 32768(0.5 世界单位),实际最大值 27479 略小,因为 fBm 归一化后取到的是 [0, ONE) 上的值而极值不到 1。

材质直方图是 [11, 229, 251, 21]——四档里中间两档占了 480/512,两端各只有 11 与 21。这不是分布写错了,而是分档是对 fBm 值做量化:fBm 的分布集中在中间,落在两端档的三角形本来就少。这里第一版还踩过一个真实的坑:写成 min(bands-1, fmul(fbm(...), bands << 16)) 时,fmul 返回的是 Q16.16 缩放的量(范围 [0, 4·65536)),min(3, …) 恒等于 3,直方图变成 [0,0,0,512]。改成 (fbm(...) * bands) >> 16 才是正确的量化。

确定性判据两条都成立:同 seed 重跑,帧 sha256 与几何 digest 都不变;换 seed,两者都变(559c64ac… → ab3e0185…,8aaf755e… → cab47858…)。几何 digest 是把 896 个三角形的 21 个整数(9 个顶点坐标 + 9 个顶点法线 + 3 个反照率)逐条按 int32 小端编码后取 sha256,所以它一字节变了就会分叉。

换 seed 后渲染差异 MAE 13.1805,比基线对程序化的 10.8484 还大——符合预期:基线到程序化只改了地形位移,而 seed A 到 seed B 连位移场和材质抖动都换了。

计数变化是有信息量的:基线阴影光线 77211 条 / 遮挡 1357 次,程序化后是 78046 条 / 1105 次。光线变多是因为位移后的地形更高、更多像素被打到;遮挡反而变少,因为法线按位移场重算后,部分表面不再朝向光源。这两个数字不能单独解读成「质量更好或更差」——它们只是同一配置下的相对比较。

耗时 6829 ms / 6599 ms 是本机(Apple M1 Pro、单线程 Python)一次实跑的墙钟值。注意它是这一节唯一不逐位可复现的数字——同一份输入重跑,耗时会在几百毫秒的范围内浮动(本节多次重跑落在 6.6–6.8 秒),而上面所有 sha256、digest、直方图、计数都逐字节相同。程序化版本并不更慢:位移和材质只在建场景时算一次,渲染链路完全没变。

P 层把确定性钉死

P 层断言五件事,其中前四件是本节的核心:基线锚定、几何 digest 与帧 sha256 与 S 层逐字节相同、同 seed 重建两次不变、换 seed 必变。

# snippet: core/test/cg_trace_10_3_procedural_scene.test.ts(节选:确定性四连)
# verify: node --test 'core/test/cg_trace_10_3_procedural_scene.test.ts'
test("基线锚定:规格场景的帧 sha256 等于卷三基线", () => {
  const { rgb } = render(buildSceneTris(spec).tris);
  assert.equal(sha(rgb), cfg.baselineSha256, "程序化改造不能把基线搞坏");
});

test("负例:换 seed 后几何与帧都必须变", () => {
  assert.notEqual(sceneDigest(trisB), sceneDigest(trisA), "换 seed 必须换场景");
  assert.equal(sceneDigest(trisB), golden.altSeed.sceneDigest);
});

第五件是不变量:896 个三角形的每一个记录(21 个整数)都必须落在 int32 内,且 BigInt.asUintN(32, v) 与 v & MASK32 一致——因为 scene_digest 是按 32 位编码的,任何越界都会让摘要悄悄丢位。

小结

程序化场景的价值不在「自动生成」这个动作,而在于它可断言。本节把 fBm 位移与噪声分档材质接进既有 scene_spec.json:基线帧 sha256 逐字节锚定 213b4130…(主光线 43250 + 点光源 33961 = 77211 条阴影光线,遮挡 1357 次,与第 4 章基线一致),位移改动全部 289 个顶点,材质直方图 [11, 229, 251, 21],同 seed 重跑的几何 digest 与帧 sha256 都不变、换 seed 两者都变。噪声到这一步走完了它的全程:从 10.1 的一个 32 位哈希,到 10.2 的 fBm 高度场,再到本节一份能被 sha256 钉住的场景。下一章换一个完全不同的几何表示——有符号距离场,看它怎么把布尔运算变成几个 min 和 max。

阅读导航:上一节:10.2 分形与地形 · 下一节:11.1 SDF 的构造与布尔运算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配