15.3 参数与质量的取舍

把分辨率、每像素样本数、最大弹射深度、BVH 叶子容量四条旋钮各转一遍,用「相对参考解的 MAE」对「本机墙钟」给出取舍表;实测样本数在 9 spp 之后饱和(MAE 只再降 1.5463 倍),叶子容量的遍历代价在 leaf=4 处取到最小值,未达预期的组合以软告警列出。

15.3 参数与质量的取舍

站内 /graphics-mobile-rendering-optimization/ 那篇在移动端实时渲染里做取舍:帧率是硬的,画质是软的,所有旋钮都往「把这一帧压进 16.7 ms」的方向拧。离线渲染把这两个角色对调——时间是软的,质量也是软的,于是「取舍」变成一件必须被量化的事:你得知道每多花一秒买回来多少质量,以及从哪一档开始,再多花时间也买不回来了。这一节就做这件事。

四条旋钮

卷三基线里能转的旋钮有四个,它们分别作用在渲染管线的不同环节:A 分辨率(80×60 → 320×240,决定每帧发多少条主光线)、B 每像素样本数(N=1,2,3,4,N×N 超采样后盒式降采样,管抗锯齿与边缘估计)、C 最大弹射深度(1,2,3,4,受门控的镜面反射,决定间接光照的层数)、D BVH 叶子容量(1,2,4,8,16,32,决定遍历与三角形测试的配比)。

质量一律写成代理指标:相对参考解(B 轴取 N=4、C 轴取 depth=4)的平均绝对误差(MAE),以及像素差占比。这些是「与参考解差多少」,不是「好不好看」——画得好看不是正确性,一句主观评价在这里没有位置。时间一律是本机(Apple M1 Pro,单线程 Python)的实测墙钟,只做本机相对比较。

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_15_3_sweep.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_15_3_sweep.py
"""15.3 的四条扰动轴:把「质量 vs 时间」的原始数据算出来,写成 JSON。

  A 分辨率 80x60..320x240 · B 每像素样本数 N=1..4(N x N 超采样 + 盒式降采样)
  C 最大弹射深度 1..4(受门控镜面反射) · D BVH 叶子容量 1..32

质量一律是**代理指标**(相对参考解的 MAE),只做相对比较——「画得好看」不是正确性。
时间一律是本机(Apple M1 Pro,单线程 Python)实测墙钟。相变阈值与软告警由
`cg_trace_15_3_warnings.py`(tier B)判定。产出:out/cg_trace_15_3_tradeoff.json
"""

import copy
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 cgtrace  # noqa: E402
import render_trace  # noqa: E402
import cg_trace_01_3_depth as d13  # noqa: E402
from cg_trace_03_common import primary_rays  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
perf = time.perf_counter
RES_SIZES = [(80, 60), (120, 90), (160, 120), (240, 180), (320, 240)]
SPP_LEVELS = [1, 2, 3, 4]
DEPTHS = [1, 2, 3, 4]
LEAVES = [1, 2, 4, 8, 16, 32]
SPP_W, SPP_H = 160, 120
DEPTH_W, DEPTH_H = 160, 120


def render_direct(spec, w, h):
    """基线直接光照渲染到 w x h,返回 (color, ms)。"""
    s = copy.deepcopy(spec)
    s["width"], s["height"] = w, h
    ctx = {"stats": {"shadow_rays": 0, "shadow_hits": 0}}
    t0 = perf()
    color, _ = cgtrace.render_trace(s, w, h, 1, render_trace.make_shader, ctx)
    return color, (perf() - t0) * 1000.0


def hit_permille(color, bg):
    mask = ~((color[:, :, 0] == bg[0]) & (color[:, :, 1] == bg[1]) & (color[:, :, 2] == bg[2]))
    return round(float(mask.sum()) * 1000.0 / mask.size, 4)


def add_ratios(rows):
    """补 maeRatio = 上一档 MAE / 本档 MAE。"""
    for i in range(1, len(rows)):
        prev, cur = rows[i - 1]["maeVsRef"], rows[i]["maeVsRef"]
        rows[i]["maeRatio"] = None if cur == 0 else round(prev / cur, 4)


def axis_resolution(base):
    """A 分辨率:代价与像素数、覆盖率估计的收敛。"""
    bg = tuple(base["backgroundQ8"])
    rows = []
    for w, h in RES_SIZES:
        color, ms = render_direct(base, w, h)
        rows.append({"w": w, "h": h, "pixels": w * h, "ms": round(ms, 1),
                     "usPerPixel": round(ms * 1000.0 / (w * h), 3),
                     "hitPermille": hit_permille(color, bg), "sha256": cgtrace.digest(color)})
    for i in range(1, len(rows)):
        rows[i]["hitDeltaPermille"] = round(rows[i]["hitPermille"] - rows[i - 1]["hitPermille"], 4)
    return {"rows": rows}


def axis_spp(base):
    """B 每像素样本数:N x N 超采样后盒式降采样,质量对最高样本数参考解。"""
    rows, ref = [], None
    for n in SPP_LEVELS:
        color, ms = render_direct(base, SPP_W * n, SPP_H * n)
        acc = color.astype(np.int64).reshape(SPP_H, n, SPP_W, n, 3).sum(axis=(1, 3))
        small = (acc // (n * n)).astype(np.uint8)  # 分子非负,// 与截断一致
        if n == SPP_LEVELS[-1]:
            ref = small
        rows.append({"n": n, "spp": n * n, "renderW": SPP_W * n, "renderH": SPP_H * n,
                     "ms": round(ms, 1), "sha256": cgtrace.digest(small), "_img": small})
    for r in rows:
        img = r.pop("_img")
        diff = np.abs(img.astype(np.int64) - ref.astype(np.int64))
        r["maeVsRef"] = round(cgtrace.mae(img, ref), 4)
        r["pixelsDifferPermille"] = round(float((diff.max(axis=2) > 0).sum())
                                          * 1000.0 / (SPP_W * SPP_H), 4)
    add_ratios(rows)
    return {"rows": rows, "referenceN": SPP_LEVELS[-1]}


def axis_depth(base):
    """C 最大弹射深度:受门控的镜面反射,质量对最深一档参考解。"""
    spec = copy.deepcopy(base)
    spec["width"], spec["height"] = DEPTH_W, DEPTH_H
    results = {}
    for depth in DEPTHS:
        t0 = perf()
        color, ctx = d13.render_at_depth(spec, depth)
        results[depth] = (color, (perf() - t0) * 1000.0, ctx)
    ref = results[DEPTHS[-1]][0]
    rows = []
    for depth in DEPTHS:
        color, ms, ctx = results[depth]
        rows.append({"maxDepth": depth, "ms": round(ms, 1),
                     "traceCalls": ctx["calls"], "reflectRays": ctx["reflect_rays"],
                     "shadowRays": ctx["stats"]["shadow_rays"],
                     "meanChannel": round(float(color.astype(np.int64).mean()), 4),
                     "maeVsRef": round(cgtrace.mae(color, ref), 4),
                     "sha256": cgtrace.digest(color)})
    add_ratios(rows)
    return {"rows": rows, "referenceDepth": DEPTHS[-1]}


def depth_of(nodes, i=0, d=0):
    """以 i 为根的子树的层数。"""
    if nodes[i][6] < 0:
        return d + 1
    return max(depth_of(nodes, nodes[i][6], d + 1), depth_of(nodes, nodes[i][9], d + 1))


def axis_leaf(base):
    """D BVH 叶子容量:纯求交的节点访问数与三角形测试数(不计着色)。"""
    tris, _ = cgtrace.build_scene_tris(base)
    far = base["camera"]["farQ16"]
    rays = primary_rays(base)
    rows = []
    for ls in LEAVES:
        nodes, order = cgtrace.build_bvh(tris, leaf_size=ls)
        st = {"visited": 0, "tested": 0}
        t0 = perf()
        for e, d in rays:
            cgtrace.bvh_intersect(nodes, order, tris, e, d, far, st)
        ms = (perf() - t0) * 1000.0
        rows.append({"leafSize": ls, "nodes": len(nodes), "levels": depth_of(nodes),
                     "visited": st["visited"], "tested": st["tested"],
                     "opsTotal": st["visited"] + st["tested"],
                     "visitedPerRay": round(st["visited"] / len(rays), 4),
                     "testedPerRay": round(st["tested"] / len(rays), 4),
                     "ms": round(ms, 1)})
    return {"rows": rows, "rays": len(rays),
            "bestLeafByOps": min(rows, key=lambda r: r["opsTotal"])["leafSize"],
            "bestLeafByMs": min(rows, key=lambda r: r["ms"])["leafSize"]}


def main():
    base = cglib.load_spec()
    os.makedirs(OUT, exist_ok=True)
    axes = {"resolution": axis_resolution(base), "spp": axis_spp(base),
            "depth": axis_depth(base), "leaf": axis_leaf(base)}
    with open(os.path.join(OUT, "cg_trace_15_3_tradeoff.json"), "w", encoding="utf-8") as fh:
        json.dump({"axes": axes}, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    a, b, c, d = axes["resolution"], axes["spp"], axes["depth"], axes["leaf"]
    print("== 15.3 四条扰动轴(原始数据,tier S)==")
    print("A 分辨率(代价与像素数、覆盖率估计):")
    for r in a["rows"]:
        print(f"  {r['w']:3d}x{r['h']:<3d} 像素 {r['pixels']:>6d}  {r['ms']:>8.1f} ms"
              f"  {r['usPerPixel']:>7.3f} us/像素  命中 {r['hitPermille']:>8}‰")
    print("B 每像素样本数(N x N 超采样后降采样,参考解 N=4):")
    for r in b["rows"]:
        ratio = "-" if r.get("maeRatio") is None else f"{r['maeRatio']}"
        print(f"  N={r['n']} spp={r['spp']:>2d} 渲染 {r['renderW']}x{r['renderH']}"
              f"  {r['ms']:>8.1f} ms  MAE {r['maeVsRef']:>8}  比上一档 {ratio}")
    print("C 最大弹射深度(受门控镜面反射,参考解 depth=4):")
    for r in c["rows"]:
        ratio = "-" if r.get("maeRatio") is None else f"{r['maeRatio']}"
        print(f"  depth={r['maxDepth']}  trace 调用 {r['traceCalls']:>7d}  反射 {r['reflectRays']:>6d}"
              f"  {r['ms']:>8.1f} ms  MAE {r['maeVsRef']:>8}  比上一档 {ratio}")
    print("D BVH 叶子容量(纯求交,19200 条主光线):")
    for r in d["rows"]:
        print(f"  LEAF_SIZE={r['leafSize']:>2d} 节点 {r['nodes']:>4d} 层 {r['levels']:>2d}"
              f"  访问 {r['visited']:>8d} 测试 {r['tested']:>8d} 合计 {r['opsTotal']:>8d}"
              f"  {r['ms']:>8.1f} ms")
    print(f"  计数最低 = {d['bestLeafByOps']};墙钟最低 = {d['bestLeafByMs']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_15_3_warnings.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/cg_trace_15_3_warnings.py
"""15.3 的 B 档:把四条扰动轴的原始数据过一遍相变阈值,**软告警不硬失败**。

  MAE_RATIO_MIN = 2.0       相邻两档之间 MAE 至少要减半,否则判「已饱和」
  HIT_DELTA_PERMILLE = 1.0  相邻分辨率之间命中占比变化 < 1‰ 视为覆盖率已收敛

未达预期的参数组合被**如实列出**,但**进程一律 exit 0**——这些是取舍的观测,不是错误。
产出:out/cg_trace_15_3_warnings.json
"""

import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
MAE_RATIO_MIN, HIT_DELTA_PERMILLE = 2.0, 1.0


def collect(a, b, c, d):
    """返回软告警列表(只观测,不抛错)。"""
    warns = []
    last = a["rows"][-1]
    if abs(last.get("hitDeltaPermille", 0.0)) > HIT_DELTA_PERMILLE:
        warns.append(f"分辨率 {last['w']}x{last['h']} 的命中占比仍在变"
                     f"(Δ={last['hitDeltaPermille']}‰ > {HIT_DELTA_PERMILLE}‰),覆盖率未收敛")
    for label, axis, key, extra in (("样本数", b, "spp", "n"), ("深度", c, "maxDepth", None)):
        for r in axis["rows"][1:]:
            if r["maeRatio"] is not None and r["maeRatio"] < MAE_RATIO_MIN:
                tag = f"{r[key]}" + (f"(N={r[extra]})" if extra else "")
                warns.append(f"{label} {tag} 相对上一档 MAE 只降 {r['maeRatio']}×"
                             f"(< {MAE_RATIO_MIN}×),已饱和")
    if d["bestLeafByOps"] != 4:
        warns.append(f"遍历代价最低的叶子容量是 {d['bestLeafByOps']},不是基线 4")
    if d["bestLeafByMs"] != d["bestLeafByOps"]:
        warns.append(f"按墙钟最低的叶子容量 {d['bestLeafByMs']} 与按计数最低的 "
                     f"{d['bestLeafByOps']} 不一致(计时噪声)")
    return warns


def main():
    axes = json.load(open(os.path.join(OUT, "cg_trace_15_3_tradeoff.json"), encoding="utf-8"))["axes"]
    warns = collect(axes["resolution"], axes["spp"], axes["depth"], axes["leaf"])
    report = {"thresholds": {"maeRatioMin": MAE_RATIO_MIN,
                             "hitDeltaPermille": HIT_DELTA_PERMILLE},
              "warnings": warns, "exitCode": 0}
    with open(os.path.join(OUT, "cg_trace_15_3_warnings.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("== 15.3 相变阈值与软告警(B 档:不硬失败,exit 0)==")
    print(f"阈值: MAE_RATIO_MIN = {MAE_RATIO_MIN} · HIT_DELTA_PERMILLE = {HIT_DELTA_PERMILLE}‰")
    print(f"WARN {len(warns)} 条:")
    for w in warns:
        print(f"  WARN {w}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
== 15.3 四条扰动轴(原始数据,tier S)==
A 分辨率(代价与像素数、覆盖率估计):
   80x60  像素   4800     422.4 ms   87.993 us/像素  命中 569.5833‰
  120x90  像素  10800     924.0 ms   85.554 us/像素  命中 569.3519‰
  160x120 像素  19200    1642.8 ms   85.565 us/像素  命中 569.4271‰
  240x180 像素  43200    3663.6 ms   84.806 us/像素  命中 569.5833‰
  320x240 像素  76800    6534.3 ms   85.082 us/像素  命中 569.4141‰
B 每像素样本数(N x N 超采样后降采样,参考解 N=4):
  N=1 spp= 1 渲染 160x120    1617.8 ms  MAE   1.2829  比上一档 -
  N=2 spp= 4 渲染 320x240    6628.9 ms  MAE   0.6196  比上一档 2.0705
  N=3 spp= 9 渲染 480x360   14569.6 ms  MAE   0.4007  比上一档 1.5463
  N=4 spp=16 渲染 640x480   27268.9 ms  MAE      0.0  比上一档 -
C 最大弹射深度(受门控镜面反射,参考解 depth=4):
  depth=1  trace 调用   19200  反射      0    1928.8 ms  MAE   3.4346  比上一档 -
  depth=2  trace 调用   30133  反射  10933    2538.9 ms  MAE   0.2134  比上一档 16.0947
  depth=3  trace 调用   31701  反射  12501    2716.9 ms  MAE   0.0246  比上一档 8.6748
  depth=4  trace 调用   32428  反射  13228    2823.3 ms  MAE      0.0  比上一档 -
D BVH 叶子容量(纯求交,19200 条主光线):
  LEAF_SIZE= 1 节点 1791 层 11  访问  1820926 测试   127744 合计  1948670    2620.9 ms
  LEAF_SIZE= 2 节点 1023 层 10  访问  1608112 测试   234727 合计  1842839    2538.7 ms
  LEAF_SIZE= 4 节点  511 层  9  访问  1347708 测试   463033 合计  1810741    2632.0 ms
  LEAF_SIZE= 8 节点  255 层  8  访问  1116086 测试   810677 合计  1926763    2955.6 ms
  LEAF_SIZE=16 节点  127 层  7  访问   926490 测试  1327172 合计  2253662    3633.7 ms
  LEAF_SIZE=32 节点   63 层  6  访问   757638 测试  2363928 合计  3121566    5205.5 ms
  计数最低 = 4;墙钟最低 = 2

四条轴的读法

A 分辨率:us/像素 稳定在 85–88 之间(80×60 是 87.993,320×240 是 85.082),说明代价与像素数基本成正比——分辨率是最诚实的一条轴,没有意外。命中占比在 569.35‰–569.58‰ 之间摆动,逐档变化最大 0.2314‰,远低于 1‰ 的收敛阈值,所以覆盖率估计已经收敛:再往上加分辨率,只是把同样的画面画得更细,不会「看出更多东西」。

B 每像素样本数:这是唯一触发相变的一条轴。N=2(4 spp)把 MAE 从 1.2829 压到 0.6196,降了 2.0705 倍;但 N=3(9 spp)只降到 0.4007,只降 1.5463 倍——低于「至少减半」的阈值。也就是说:从 9 spp 开始,样本数翻倍带来的质量提升不再翻倍,继续加样本是低效的。注意 N=4 的定义就是参考解本身(MAE = 0),它花掉 27268.9 ms,是 N=1 的 16.9 倍。

C 最大弹射深度:depth 1→2 降 16.0947 倍,2→3 降 8.6748 倍,都还在阈值之上,所以没有触发「饱和」告警。但趋势是清楚的:每加一层弹射,收益减半左右,而成本只涨 7–8%(2538.9 → 2716.9 ms)。depth=3 的 MAE 只有 0.0246(平均通道 62.5686 对参考解 62.5932),已经小到肉眼不可分辨。

D BVH 叶子容量:这条轴给出一个U 形。访问节点数随叶子变大单调下降(23.71 → 9.87 个/光线),三角形测试数单调上升(1.6633 → 30.7803 个/光线)——两者此消彼长,合计 访问 + 测试 在 leaf=4 处取到最小值 1810741,叶子再大就反转(leaf=32 涨到 3121566,是 leaf=4 的 1.72 倍)。这正是卷三基线选 leaf=4 的理由,也是「叶子容量不是越大越好」的定量证据。

相变阈值与软告警

== 15.3 相变阈值与软告警(B 档:不硬失败,exit 0)==
阈值: MAE_RATIO_MIN = 2.0 · HIT_DELTA_PERMILLE = 1.0‰
WARN 2 条:
  WARN 样本数 9(N=3) 相对上一档 MAE 只降 1.5463×(< 2.0×),已饱和
  WARN 按墙钟最低的叶子容量 2 与按计数最低的 4 不一致(计时噪声)
轴阈值判据触发档结论
A 分辨率Δ命中 < 1‰未触发(最大 Δ=0.2314‰)覆盖率已收敛
B 样本数MAE 比 ≥ 2×N=3 只降 1.5463×已饱和
C 深度MAE 比 ≥ 2×未触发(16.09× / 8.67×)未饱和
D 叶子容量计数最低应为 4计数最低 = 4通过(墙钟最低 = 2,见下)
两条 WARN 都是如实记录的「未达预期」,而不是错误——脚本 exit 0:样本数在 9 spp 之后饱和,这不是 bug,是超采样收敛速度的自然结果(盒式降采样把 N×N 个样本平均,误差大致按 1/N 下降而非 1/N²,「翻倍样本 → MAE 减半」的规则迟早会破);按计数最低的叶子容量(4)与按墙钟最低的(2)不一致,这条是计时噪声,而且它复现了 15.1 的教训——leaf=2 的墙钟 2538.7 ms 与 leaf=4 的 2632.0 ms 只差 3.5%,而计数上 leaf=2 的合计 1842839 比 leaf=4 的 1810741 高 1.77%,计数说得比秒表准,所以基线坚持用计数而不是墙钟来定叶子容量。

小结

  • 四条旋钮里只有「每像素样本数」触发了相变:9 spp 之后 MAE 只再降 1.5463 倍,继续加样本不划算;分辨率代价与像素数成正比(85–88 μs/像素),弹射深度每加一层收益减半(16.09× → 8.67×)。
  • 叶子容量的遍历代价呈 U 形,leaf=4 是计数最小值(1810741);两条「未达预期」以软告警如实列出、进程 exit 0——取舍的观测不是错误。

阅读导航:上一节:15.2 性能的度量与剖析 · 下一节:16.1 渲染器的全链路回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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