16.3 交付卷二

把规格里的分辨率、FOV、子像素位数与三角形数量四条轴分别拧出基线区间:给出走样阈值、相变阈值与每一档的代价曲线,并说明子像素位数是规格契约而非精度旋钮;四条轴的每一档摘要都由 TypeScript 侧逐字节复现,B 档越界只告警不中断,判据稳定可复跑。

16.3 交付卷二

Unity/SRP 口径的渲染管线 讲的是引擎层怎么把管线配起来、怎么调参数;本节反过来,把可执行规格里的参数拧出基线区间,看这条管线在哪里开始走样、在哪里发生相变、代价怎么涨。装配(16.1)与三宿主联调(16.2)已经证明「同一份规格能复现同一张图」,这一节回答的是「这份规格的边界在哪」。

B 档的语义:软告警不硬失败

本节属于 B 档,判据性质与 S/P/W 三档完全不同:S/P/W 是硬断言(不满足即 exit 1),B 档是软告警——阈值越界只打印 WARN,进程一律 exit 0。理由是参数扰动本来就会把管线推到规格区间之外,那里没有「正确值」,只有权衡曲线;把权衡曲线变成硬失败,等于把设计决策写死成测试。四条轴各自盯住一个失效模式;其中前两条是阈值(越界即告警),后两条是契约与代价(越界说明规格被改动、或性能开始恶化):

轴拧的旋钮观察量阈值
A 分辨率width / height走样量(与 2x 超采样降采样的通道 MAE)MAE <= 0.15
B FOVcamera.tanHalfFovYQ16覆盖像素数、相机后方顶点数覆盖 >= 画布一半
C 子像素位数subpixelBits像素缓冲 sha256必须与规格声明一致
D 三角形数量terrain.gridN覆盖像素、单三角形耗时每三角形 > 60 us

四条轴一次跑完

脚本沿用 S 侧的可参数化定点管线 sim/cg_raster_13_pipeline.make_pipeline——它与 TypeScript 侧逐字节对齐,所以扰动出来的摘要可以直接拿去和 P 层核对。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_16_3_perturbation.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/cg_raster_16_3_perturbation.py
"""16.3 交付卷二前的参数扰动:分辨率 / FOV / 子像素位数 / 三角形数量四条轴,给出
走样与相变的阈值与每一档的代价曲线。B 档**软告警不硬失败**:阈值只打印 WARN,进程一律
exit 0;数字全部来自本机实跑,渲染走可参数化定点管线 sim/cg_raster_13_pipeline。
产物:out/cg_raster_16_3_perturbation.json。
"""

import copy
import hashlib
import json
import os
import sys
import time

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cg_raster_13_pipeline  # noqa: E402
import render_raster  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RENDER = cg_raster_13_pipeline.make_pipeline({})
ALIAS_MAX = 0.15
FOVS = [(10, 11556), (15, 17560), (20, 23853), (30, 37837), (40, 54991),
        (50, 78103), (54, 90203), (60, 113512), (70, 180059)]
SUBS = [4, 5, 6, 7, 8]
GRIDS = [8, 12, 16, 20, 24]

def render(spec, mode=None):
    """渲染一帧,返回 (uint8 图像, covered, culled, ms)。"""
    verts, tris, vcolors = render_raster.build_scene(spec)
    fn = RENDER if mode is None else cg_raster_13_pipeline.make_pipeline(mode)
    t0 = time.perf_counter()
    color, covered, culled = fn(spec, verts, tris, vcolors)
    ms = (time.perf_counter() - t0) * 1000
    return np.clip(color, 0, 255).astype(np.uint8), int(np.asarray(covered).sum()), int(culled), ms


def digest(img): return hashlib.sha256(np.ascontiguousarray(img).tobytes()).hexdigest()

def behind_of(spec):
    """近平面剔除的相变指标:相机后方顶点数 + 整三角形丢弃数。"""
    cam, w, h = spec["camera"], spec["width"], spec["height"]
    view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    proj = cglib.mat_perspective(cam["tanHalfFovYQ16"],
                                 cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h)),
                                 cam["nearQ16"], cam["farQ16"])
    mvp = cglib.mat_mul(proj, view)
    verts, tris, _ = render_raster.build_scene(spec)
    behind = [cglib.project(mvp, v) is None for v in verts]
    return sum(behind), sum(1 for a, b, c in tris if behind[a] or behind[b] or behind[c])

def axis_resolution(base):
    """分辨率:直接渲染 vs 2x 超采样降采样的通道 MAE(走样量),加覆盖率与耗时。"""
    rows = []
    for w, h in [(160, 120), (240, 180), (320, 240), (480, 360), (640, 480)]:
        spec = copy.deepcopy(base)
        spec["width"], spec["height"] = w, h
        img, covered, _c, ms = render(spec)
        big = copy.deepcopy(spec)
        big["width"], big["height"] = 2 * w, 2 * h
        ref, _v, _l, _m = render(big)
        box = (ref[0::2, 0::2].astype(np.int64) + ref[1::2, 0::2]
               + ref[0::2, 1::2] + ref[1::2, 1::2]) >> 2
        rows.append({"w": w, "h": h, "covered": covered,
                     "covered_permille": covered * 1000 // (w * h),
                     "aliasing_mae": round(float(np.abs(img.astype(np.int64) - box).mean()), 4),
                     "ms": round(ms, 1), "digest": digest(img)})
    conv = [r["w"] for r in rows if r["aliasing_mae"] <= ALIAS_MAX]
    return {"rows": rows, "aliasing_threshold_w": min(conv) if conv else None}

def axis_fov(base):
    """FOV:视野收窄 -> 地形占满更多画布;同时检查近平面剔除是否相变。"""
    rows = []
    for deg, tan in FOVS:
        spec = copy.deepcopy(base)
        spec["camera"]["tanHalfFovYQ16"] = tan
        img, covered, culled, ms = render(spec)
        behind, dropped = behind_of(spec)
        rows.append({"deg": deg, "tan_q16": tan, "covered": covered, "culled": culled,
                     "behind_vertices": behind, "dropped_triangles": dropped,
                     "ms": round(ms, 1), "digest": digest(img)})
    half = base["width"] * base["height"] // 2
    ok = [r["deg"] for r in rows if r["covered"] >= half]
    return {"rows": rows, "canvas_half": half, "covered_half_max_deg": max(ok) if ok else None,
            "first_drop_deg": next((r["deg"] for r in rows if r["dropped_triangles"] > 0), None)}

def axis_subpixel(base):
    """子像素位数:它是规格的一部分,不是精度旋钮——偏离声明值即改变采样位置与摘要。"""
    rows = []
    for sub in SUBS:
        spec = copy.deepcopy(base)
        spec["subpixelBits"] = sub
        img, covered, _c, ms = render(spec, {"sub": sub})
        rows.append({"sub": sub, "covered": covered, "ms": round(ms, 1), "digest": digest(img)})
    ref = next(r["digest"] for r in rows if r["sub"] == base["subpixelBits"])
    for r in rows:
        r["matches_spec"] = r["digest"] == ref
    return {"rows": rows, "spec_sub": base["subpixelBits"],
            "matching_subs": [r["sub"] for r in rows if r["matches_spec"]]}

def axis_triangles(base):
    """三角形数量:地形格点 8..24(球固定 subdiv=2),给代价曲线。"""
    rows = []
    for n in GRIDS:
        spec = copy.deepcopy(base)
        spec["terrain"]["gridN"] = n
        verts, tris, vcolors = render_raster.build_scene(spec)
        t0 = time.perf_counter()
        color, covered, culled = RENDER(spec, verts, tris, vcolors)
        ms = (time.perf_counter() - t0) * 1000
        nt = len(tris)
        rows.append({"gridN": n, "vertices": len(verts), "triangles": nt,
                     "covered": int(np.asarray(covered).sum()), "culled": int(culled),
                     "ms": round(ms, 1), "us_per_tri": round(ms * 1000 / nt, 1),
                     "digest": digest(np.clip(color, 0, 255).astype(np.uint8))})
    return {"rows": rows}

def main():
    base = cglib.load_spec()
    a, b, c, d = axis_resolution(base), axis_fov(base), axis_subpixel(base), axis_triangles(base)

    warns = [f"分辨率 {r['w']}x{r['h']} 走样 MAE {r['aliasing_mae']} > {ALIAS_MAX}"
             for r in a["rows"] if r["aliasing_mae"] > ALIAS_MAX]
    warns += [f"FOV {r['deg']}° 覆盖跌破画布一半({r['covered']} < {b['canvas_half']})"
              for r in b["rows"] if r["covered"] < b["canvas_half"]]
    warns += [f"FOV {r['deg']}° 触发近平面剔除 {r['dropped_triangles']} 个三角形"
              for r in b["rows"] if r["dropped_triangles"] > 0]
    warns += [f"子像素 {r['sub']} 位摘要与规格声明的 {c['spec_sub']} 位分叉"
              for r in c["rows"] if not r["matches_spec"]]
    warns += [f"gridN={r['gridN']} 单三角形耗时 {r['us_per_tri']} us 偏高"
              for r in d["rows"] if r["us_per_tri"] > 60]

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    stats = {"canvas": [base["width"], base["height"]],
             "axes": {"resolution": a, "fov": b, "subpixel": c, "triangles": d},
             "warnings": warns, "exit_code": 0}
    with open(os.path.join(out, "cg_raster_16_3_perturbation.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print("A 分辨率(走样量 = 与 2x 超采样降采样的通道 MAE):")
    for r in a["rows"]:
        print(f"  {r['w']:4d}x{r['h']:<4d} 覆盖 {r['covered']:6d} ({r['covered_permille']:3d}‰)"
              f"  走样MAE {r['aliasing_mae']:6.4f}  {r['ms']:7.1f} ms")
    print(f"  走样阈值: MAE <= {ALIAS_MAX} 所需最小宽度 = {a['aliasing_threshold_w']} px")
    print("B FOV(视野收窄 -> 地形占满更多画布;检查近平面剔除是否相变):")
    for r in b["rows"]:
        print(f"  {r['deg']:3d}° tanQ16={r['tan_q16']:7d} 覆盖 {r['covered']:6d}"
              f"  剔除 {r['culled']:4d}  相机后方顶点 {r['behind_vertices']:3d}"
              f"  丢弃三角形 {r['dropped_triangles']:3d}  {r['ms']:7.1f} ms")
    print(f"  相变阈值: 覆盖 >= 画布一半({b['canvas_half']}) 的最大半视角 = "
          f"{b['covered_half_max_deg']}°;近平面丢弃首现于 {b['first_drop_deg']}")
    print(f"C 子像素位数(1 像素 = 2^sub 单位;规格声明 {c['spec_sub']} 位):")
    for r in c["rows"]:
        print(f"  sub={r['sub']}  覆盖 {r['covered']:6d}  {r['ms']:7.1f} ms"
              f"  摘要 {r['digest'][:16]}  与规格一致 = {r['matches_spec']}")
    print(f"  与规格一致的子像素位数: {c['matching_subs']}")
    print("D 三角形数量(地形格点 8..24,球固定 subdiv=2):")
    for r in d["rows"]:
        print(f"  gridN={r['gridN']:2d}  顶点 {r['vertices']:4d}  三角形 {r['triangles']:5d}"
              f"  覆盖 {r['covered']:6d}  {r['ms']:7.1f} ms  {r['us_per_tri']:5.1f} us/三角形")
    print("")
    print(f"WARN {len(warns)} 条(B 档软告警,exit 0):")
    for w in warns:
        print(f"  WARN {w}")
    return 0


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

实跑输出(计时逐次波动,同机重跑有 ±20% 抖动,覆盖数与摘要完全稳定;下面是 A~C 三轴,D 轴见下一节):

A 分辨率(走样量 = 与 2x 超采样降采样的通道 MAE):
   160x120  覆盖  10934 (569‰)  走样MAE 0.3792     32.5 ms
   240x180  覆盖  24608 (569‰)  走样MAE 0.2577     28.0 ms
   320x240  覆盖  43731 (569‰)  走样MAE 0.2161     26.6 ms
   480x360  覆盖  98394 (569‰)  走样MAE 0.1371     33.6 ms
   640x480  覆盖 174941 (569‰)  走样MAE 0.1095     43.0 ms
  走样阈值: MAE <= 0.15 所需最小宽度 = 480 px
B FOV(视野收窄 -> 地形占满更多画布;检查近平面剔除是否相变):
   10° tanQ16=  11556 覆盖  63015  剔除  291  相机后方顶点   0  丢弃三角形   0     19.0 ms
   15° tanQ16=  17560 覆盖  53486  剔除  290  相机后方顶点   0  丢弃三角形   0     28.5 ms
   20° tanQ16=  23853 覆盖  48546  剔除  290  相机后方顶点   0  丢弃三角形   0     24.4 ms
   30° tanQ16=  37837 覆盖  43731  剔除  290  相机后方顶点   0  丢弃三角形   0     26.8 ms
   40° tanQ16=  54991 覆盖  40684  剔除  290  相机后方顶点   0  丢弃三角形   0     29.6 ms
   50° tanQ16=  78103 覆盖  38549  剔除  290  相机后方顶点   0  丢弃三角形   0     27.8 ms
   54° tanQ16=  90203 覆盖  37990  剔除  291  相机后方顶点   0  丢弃三角形   0     28.3 ms
   60° tanQ16= 113512 覆盖  33316  剔除  291  相机后方顶点   0  丢弃三角形   0     27.2 ms
   70° tanQ16= 180059 覆盖  19087  剔除  290  相机后方顶点   0  丢弃三角形   0     26.3 ms
  相变阈值: 覆盖 >= 画布一半(38400) 的最大半视角 = 50°;近平面丢弃首现于 None
C 子像素位数(1 像素 = 2^sub 单位;规格声明 6 位):
  sub=4  覆盖  43737     26.2 ms  摘要 db7a9c9c493e2912  与规格一致 = False
  sub=5  覆盖  43731     26.0 ms  摘要 85eb940d237d1b20  与规格一致 = False
  sub=6  覆盖  43731     26.6 ms  摘要 bac659770178037a  与规格一致 = True
  sub=7  覆盖  43731     25.3 ms  摘要 0014697cd542f1ab  与规格一致 = False
  sub=8  覆盖  43729     26.4 ms  摘要 e37ade5f86d7eb7c  与规格一致 = False
  与规格一致的子像素位数: [6]

本轮共 10 条 WARN,分三类:分辨率 3 条(160x120 / 240x180 / 320x240 走样 MAE 超过 0.15)、FOV 3 条(54° / 60° / 70° 覆盖跌破画布一半)、子像素 4 条(4/5/7/8 位摘要与规格声明的 6 位分叉)。没有一条让进程失败——这就是 B 档。

A 分辨率:走样阈值 480 px

走样量的定义是「直接渲染」与「2x 超采样再降采样」之间的通道 MAE——超采样结果被当作参考,两者差多少就是直接渲染丢掉了多少高频信息。五档里只有 480x360 与 640x480 把 MAE 压到 0.15 以下,所以阈值是 480 px:320x240 的 MAE 是 0.2161,已经越界。这条曲线单调,也符合直觉——分辨率越高,一个像素覆盖的几何越少,边缘的覆盖率近似越准。值得注意的是覆盖率在所有分辨率下都是 569‰:这不是巧合,而是几何相似性的直接结果——画布与场景同步缩放,投影后的地形占画布的比例不变。所以 A 轴上「覆盖像素数」是等比的,只有走样量才是真正的失效指标。

B FOV:相变阈值 50°

FOV 这一轴的假设一开始是「视野收窄会把顶点推到相机后方,触发近平面剔除相变」。实测证伪了这个假设:从 10° 到 70°,相机后方顶点数恒为 0,整三角形丢弃数也恒为 0——近平面剔除在这一帧从头到尾都是空转的。这条结论比一个漂亮的相变曲线更有价值:它说明近平面剔除的触发条件是相机与几何的相对位置,不是 FOV。于是相变的判据改成覆盖率:覆盖像素数随 FOV 单调下降(63015 -> 19087),把「覆盖跌破画布一半(38400)」定为相变点,它落在 50° 与 54° 之间,所以阈值是 50°。窄 FOV 时地形占满画布,宽 FOV 时地形缩成中央一小块——这条曲线同时是「视野与画布占比」的换算表。

C 子像素位数:不是精度旋钮,是规格契约

这一轴最容易误判。直觉上「子像素位数」像是精度旋钮——位数越多越精确,所以应该取大的。实测否定了这个直觉:五个档里只有 6 位与规格声明的摘要一致,4/5/7/8 位全部与基线分叉。原因是子像素位数决定了像素中心的采样位置(px*64 + 32 里的 64 就是 1 << 6),改动它会整体重排采样点,于是覆盖判定、插值权重、深度值全都跟着变。所以子像素位数是规格的一部分:它参与定义「这张图长什么样」,不是可以随手调的精度参数。这条纪律反过来解释了 13.1 为什么反复强调「降精度一律用算术右移 >>」——一旦采样位置被改动,跨语言的逐字节一致就无从谈起。

D 三角形数量:代价曲线

最后一轴是纯代价:地形格点从 8 涨到 24,三角形从 512 涨到 1536(3 倍),耗时从 17.1 ms 涨到 41.8 ms(2.4 倍),而单三角形耗时反而从 33.3 us 降到 27.2 us——固定开销(场景构建、缓冲分配)被摊薄了。覆盖率几乎不变(43544 -> 43857),因为地形格点只改变网格密度,不改变地形轮廓。这条曲线给出一个可用的预算公式:每三角形约 30 us(320x240、定点软光栅化、本机单核)。要在这一档预算下把帧率翻倍,只能减三角形或减分辨率,没有第三条路。

跨语言:扰动摘要也必须逐字节相同

B 档的每一档摘要都被 TypeScript 侧独立复现,node --test 断言四条轴的每一档 sha256 与 Python 侧逐字节相同;负例是「偏离规格声明的子像素位数」——摘要必须与基线分叉,且五个档的摘要两两不同。

// file: lantern/core/test/cg_raster_16_3_perturbation.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_16_3_perturbation.test.ts'
// 16.3 的跨语言核对:B 档四条扰动轴(分辨率 / FOV / 子像素位数 / 三角形数量)的每一档摘要,
// TypeScript 侧必须与 Python 侧 sim/cg_raster_16_3_perturbation.py **逐字节相同**。
// 负例:偏离规格声明的子像素位数,摘要必须与基线分叉,且五个档的摘要两两不同。

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import { type SceneSpec } from "../src/lantern/pipeline.ts";
import { type Mode, renderParam } from "../src/lantern/cg_raster_13_pipeline.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const BASE_SPEC = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as SceneSpec;
const g = JSON.parse(readFileSync(join(root, "out", "cg_raster_16_3_perturbation.json"), "utf8")) as {
  axes: {
    resolution: { rows: { w: number; h: number; covered: number; digest: string }[] };
    fov: { rows: { deg: number; tan_q16: number; covered: number; digest: string }[] };
    subpixel: { spec_sub: number; rows: { sub: number; covered: number; digest: string }[] };
    triangles: { rows: { gridN: number; covered: number; digest: string }[] };
  };
};
const BASE = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced";
const sha = (b: Uint8Array): string => createHash("sha256").update(Buffer.from(b)).digest("hex");
const clone = (s: SceneSpec): SceneSpec => JSON.parse(JSON.stringify(s)) as SceneSpec;

/** 四条轴共用:改规格 -> 渲染 -> 与 Python 侧该档的摘要、覆盖数对账。 */
function check(mode: Mode, mutate: (s: SceneSpec) => void, row: { covered: number; digest: string }, tag: string): void {
  const spec = clone(BASE_SPEC);
  mutate(spec);
  const r = renderParam(spec, mode);
  assert.equal(sha(r.rgb), row.digest, `${tag} 摘要与 Python 侧不符`);
  assert.equal(r.covered, row.covered, `${tag} 覆盖数不符`);
}

test("四条扰动轴:每一档摘要与 Python 侧逐字节相同", () => {
  for (const row of g.axes.subpixel.rows) check({ sub: row.sub }, () => {}, row, `sub=${row.sub}`);
  for (const row of g.axes.fov.rows) check({}, (s) => { s.camera.tanHalfFovYQ16 = row.tan_q16; }, row, `FOV ${row.deg}°`);
  for (const row of g.axes.resolution.rows) check({}, (s) => { s.width = row.w; s.height = row.h; }, row, `${row.w}x${row.h}`);
  for (const row of g.axes.triangles.rows) check({}, (s) => { s.terrain.gridN = row.gridN; }, row, `gridN=${row.gridN}`);
});

test("负例:偏离规格声明的子像素位数,摘要必须分叉且五档两两不同", () => {
  assert.equal(g.axes.subpixel.spec_sub, 6);
  assert.equal(sha(renderParam(BASE_SPEC, { sub: 6 }).rgb), BASE, "规格声明的 6 位必须复现基线");
  for (const row of g.axes.subpixel.rows) {
    if (row.sub !== 6) assert.notEqual(row.digest, BASE, `sub=${row.sub} 未分叉`);
  }
  const digests = g.axes.subpixel.rows.map((r) => r.digest);
  assert.equal(new Set(digests).size, digests.length, "五个子像素档的摘要必须两两不同");
});
✔ 四条扰动轴:每一档摘要与 Python 侧逐字节相同
✔ 负例:偏离规格声明的子像素位数,摘要必须分叉且五档两两不同
ℹ tests 2  ℹ pass 2  ℹ fail 0

两条全绿。这一步把 B 档和 S/P 档缝在了一起:扰动轴不只是「跑出一些数」,而是每一档的像素缓冲都有跨语言的唯一摘要——如果哪一档的摘要在两个宿主间分叉,说明扰动路径上有一处实现不忠实。

交付

卷一至此收束。这份交付包含三件东西:一条十级装配的管线(16.1)、一份三宿主联调的判据(16.2),以及一份参数扰动的边界表(本节)。三者的共同底座是一份可执行规格 config/scene_spec.json——它不描述「怎么渲染」,而是描述「渲染什么」,所以同一个文件可以被 Python、TypeScript 与浏览器三个宿主消费。下一卷要在这条管线的着色阶段继续深入:现在的片元颜色来自逐顶点的 Lambert 全整数着色,下一卷会把光照模型、纹理采样与颜色管理全部整数化,并在同一套判据下验证。管线骨架(装配、深度、覆盖)在本卷已经封版,下一卷只替换着色环节——这正是把规格与实现分离的收益:换着色不动装配,换装配不动规格。

阅读导航:上一节:16.2 三种消费的联调 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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