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 渲染器的全链路回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。