10.3 UI 合批与性能
10.2 多分辨率资源分级 管的是「发多大的图」;这一节管「怎么少切换」。UI 的 draw call 数由相邻元件的图集与裁剪域决定:同一个图集、同一个裁剪域、在绘制序列里挨着,就能并成一次;中间插进一个别的图集,就得断开。所以合批不是渲染器的黑魔法,而是绘制顺序的账——只要能把顺序与归因算清楚,就能在美术侧改版式,而不是等性能分析器来报。
10.3.1 合批规则与预算
config/ta10_batch.json 里 20 个元件各带 atlas(ui_common / ui_icons / ui_portraits)与 clip(root / panel_shop / panel_inv),预算 drawCallMax=10。规则两条:相邻元件 atlas 与 clip 都相同才并批,否则开新批;断开时按优先级归因——atlas 不同记 atlas-change,否则 clip 不同记 clip-change。归因的作用是改版式的指路牌:atlas-change 占多数说明该合并图集,clip-change 占多数说明该调裁剪域嵌套。
10.3.2 S 层:批次划分与归因
下面这段是 S 层脚本,算作者顺序与「按 (atlas, clip) 稳定排序」两套批次划分,并给出降幅:
#!/usr/bin/env python3
# file: sim/ta10_batch.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta10_batch.py
"""UI 合批与归因账(S 层,卷二 10.3)。相邻元件 atlas 与 clip 都相同才并批,否则开新批并按优先级
归因(atlas 优先于 clip);输出作者顺序与按 (atlas, clip) 稳定排序后两套,给出降幅千分比。全整数。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta10_batch.json")
OUT = os.path.join(ROOT, "out")
def batch(widgets):
"""返回 (batches, breaks);breaks 记 [{index, id, reason}]。"""
batches, breaks, cur, prev = [], [], [], None
for w in widgets:
if prev and (w["atlas"] != prev["atlas"] or w["clip"] != prev["clip"]):
breaks.append({"index": len(batches), "id": w["id"],
"reason": "atlas-change" if w["atlas"] != prev["atlas"] else "clip-change"})
batches.append(cur)
cur = []
cur.append(w["id"])
prev = w
if cur:
batches.append(cur)
return batches, breaks
def reason_counts(breaks):
out = {}
for b in breaks:
out[b["reason"]] = out.get(b["reason"], 0) + 1
return out
def pack(widgets, bmax):
b, br = batch(widgets)
return {"batches": b, "batchCount": len(b), "breakCount": len(br), "breaks": br,
"byReason": reason_counts(br), "overBudget": len(b) > bmax}
def main():
cfg = json.load(open(CFG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
ws, bmax = cfg["widgets"], cfg["budget"]["drawCallMax"]
ordered = sorted(ws, key=lambda w: (w["atlas"], w["clip"]))
assert sorted(w["id"] for w in ws) == sorted(w["id"] for w in ordered), "排序改变了元件集合"
naive, sb = pack(ws, bmax), pack(ordered, bmax)
lines = [f"{i}:{','.join(m)}" for i, m in enumerate(naive["batches"])]
rep = {"project": cfg["project"], "widgetCount": len(ws), "drawCallMax": bmax,
"naive": naive, "sorted": sb, "reduced": naive["batchCount"] - sb["batchCount"],
"reducedPermille": (naive["batchCount"] - sb["batchCount"]) * 1000 // naive["batchCount"],
"digest": hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]}
with open(os.path.join(OUT, "ta10_batch.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
for name in ("naive", "sorted"):
b = rep[name]
print(f" {name:<7} batches={b['batchCount']} breaks={b['breakCount']} "
f"byReason={b['byReason']} overBudget={b['overBudget']}")
print(f"reduced={rep['reduced']} reducedPermille={rep['reducedPermille']} digest={rep['digest']}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta10_batch.py:
naive batches=12 breaks=11 byReason={'clip-change': 2, 'atlas-change': 9} overBudget=True
sorted batches=7 breaks=6 byReason={'clip-change': 4, 'atlas-change': 2} overBudget=False
reduced=5 reducedPermille=416 digest=c35e28192d63607c
作者顺序 12 批、超预算(drawCallMax=10),断开 11 次里 9 次是 atlas-change;按 (atlas, clip) 稳定排序后 7 批、达标,断开降到 6 次且归因变成 clip-change 略多。降幅 5 批 = 416 千分比。这就是「改排序就能省 5 次切换」的量化说法——但代价是元件顺序不再是作者画的顺序,遮挡关系要另行保证。
10.3.3 P 层:同一套规则的 TS 内核
下面这段是 P 层内核,与 Python 逐条对应——同一个「相邻同键才并批」、同一个 atlas 优先归因、同一个稳定排序:
// file: core/src/ta10/batch.ts
// tier: P
// verify: node --test 'core/test/ta10_*.test.ts'
// 卷二 10.3 UI 合批的 TS 侧实现。与 sim/ta10_batch.py 同一套规则:相邻元件 atlas 与 clip 都相同
// 才并批,否则开新批并按优先级归因(atlas 优先于 clip)。排序对照=按 (atlas, clip) 稳定排序。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export type Widget = { id: string; atlas: string; clip: string };
export type Break = { index: number; id: string; reason: string };
export function batch(widgets: Widget[]): { batches: string[][]; breaks: Break[] } {
const batches: string[][] = [];
const breaks: Break[] = [];
let cur: string[] = [];
let prev: Widget | null = null;
for (const w of widgets) {
if (prev && (w.atlas !== prev.atlas || w.clip !== prev.clip)) {
breaks.push({ index: batches.length, id: w.id, reason: w.atlas !== prev.atlas ? "atlas-change" : "clip-change" });
batches.push(cur);
cur = [];
}
cur.push(w.id);
prev = w;
}
if (cur.length > 0) batches.push(cur);
return { batches, breaks };
}
export function reasonCounts(breaks: Break[]): Record<string, number> {
const out: Record<string, number> = {};
for (const b of breaks) out[b.reason] = (out[b.reason] ?? 0) + 1;
return out;
}
/** 稳定排序:按 (atlas, clip),同键保持原顺序。 */
export function sortForBatch(widgets: Widget[]): Widget[] {
const key = (w: Widget) => `${w.atlas}\u0000${w.clip}`;
return widgets.map((w, i) => ({ w, i }))
.sort((a, b) => (key(a.w) < key(b.w) ? -1 : key(a.w) > key(b.w) ? 1 : a.i - b.i))
.map((x) => x.w);
}
export function batchLines(batches: string[][]): string[] {
return batches.map((m, i) => `${i}:${m.join(",")}`).sort();
}
export function digest16(lines: string[]): string {
return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}
P 层测试读的是 Python 落盘的 out/ta10_batch.json(不是同一份源码),做跨语言对照。实跑输出(node --test core/test/ta10_batch.test.ts):
✔ 合批:作者顺序的批次划分与 Python 逐位一致
✔ 排序对照:按 (atlas, clip) 稳定排序后批次数与 Python 一致
✔ 预算判定:作者顺序超预算,排序后达标
ℹ tests 3
ℹ pass 3
ℹ fail 0
两侧的合批 digest 都是 c35e28192d63607c——整数类,逐位一致。排序用的是 \u0000 分隔的复合键加下标兜底,保证同键保持原顺序(稳定);若换成不带下标的比较器,V8 与 CPython 在等键元素上的相对顺序没有共同约定,批次数虽不变,但批次内部成员会分叉,digest 立刻对不上。
10.3.4 W 层:把两套划分画成色带
下面这段是 W 层构建脚本,把 spec.ts 与 batch.ts 擦除类型后内联进单文件 HTML,画上下两条 20 格色带:上带是作者顺序,下带是稳定排序后,同批同色、跨批换色:
// file: web/build_ta10_batch.mjs
// tier: W
// verify: node web/build_ta10_batch.mjs
// 卷二 10.3 W 层:把 core/src/sp00/spec.ts + ta10/batch.ts 擦除类型后内联进单文件 HTML。
// 页面画两条 20 格色带:上=作者顺序(12 批),下=按 (atlas, clip) 稳定排序后(7 批),
// 同批的格子同色、跨批换色。
import { stripTypeScriptTypes } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta10_batch.json"), "utf8"));
const strip = (p) => stripTypeScriptTypes(readFileSync(resolve(ROOT, p), "utf8"), { mode: "strip" })
.replace(/^import[^;]*;\s*$/gm, "").replace(/^export\s*\{[^}]*\};?\s*$/gm, "").replace(/^export\s+/gm, "");
const js = [strip("core/src/sp00/spec.ts"), strip("core/src/ta10/batch.ts")].join("\n");
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>prism UI 合批 · ${cfg.project}</title>
<style>body{margin:0;padding:20px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui}
canvas{display:block;border:1px solid #3d4a57;image-rendering:pixelated}table{border-collapse:collapse;font:12px/1.5 ui-monospace,monospace;margin-top:14px}th,td{text-align:left;padding:3px 10px;border-bottom:1px solid #2d3a47}td.n{text-align:right}.row{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}.lab{font:12px/1.6 ui-monospace,monospace;color:#94a3b8;margin:6px 0 2px}</style></head><body>
<h1>UI 合批看板</h1>
<div style="color:#94a3b8;font-size:12px">每格一个元件 · 同批同色 · 上=作者顺序 下=按 (atlas, clip) 稳定排序</div>
<div class="row"><div><div class="lab" id="l1"></div><div class="lab" id="l2"></div>
<canvas id="strip" width="360" height="190"></canvas></div>
<table id="br"><thead><tr><th>口径</th><th class="n">批次</th><th class="n">断开</th><th>归因</th><th>预算</th></tr></thead><tbody></tbody></table>
</div>
<script type="module">
${js}
const SPEC = ${JSON.stringify(spec)};
const CFG = ${JSON.stringify(cfg)};
const PALETTE = [[239,68,68],[245,158,11],[34,197,94],[56,189,248],[168,85,247],[236,72,153],
[20,184,166],[250,204,21],[99,102,241],[244,114,182],[132,204,22],[249,115,22]];
const CELL = 36, COLS = 10, STRIP_H = 72, S1Y = 12, S2Y = S1Y + STRIP_H + 28;
const colorOf = (bi) => PALETTE[bi % PALETTE.length];
const nb = batch(CFG.widgets);
const sb = batch(sortForBatch(CFG.widgets));
const idIndex = new Map(CFG.widgets.map((w, i) => [w.id, i]));
const naiveBatchOf = new Array(CFG.widgets.length).fill(-1), sortedBatchOf = new Array(CFG.widgets.length).fill(-1);
nb.batches.forEach((m, bi) => m.forEach((id) => { naiveBatchOf[idIndex.get(id)] = bi; }));
sb.batches.forEach((m, bi) => m.forEach((id) => { sortedBatchOf[idIndex.get(id)] = bi; }));
const ctx = document.getElementById("strip").getContext("2d");
ctx.fillStyle = "rgb(" + SPEC.tokens.bg.join(",") + ")"; ctx.fillRect(0, 0, 360, 190);
const cellAt = (i, y0) => ({ x: (i % COLS) * CELL, y: y0 + Math.floor(i / COLS) * CELL });
for (let i = 0; i < CFG.widgets.length; i += 1) {
const p1 = cellAt(i, S1Y), p2 = cellAt(i, S2Y);
ctx.fillStyle = "rgb(" + colorOf(naiveBatchOf[i]).join(",") + ")"; ctx.fillRect(p1.x + 1, p1.y + 1, CELL - 2, CELL - 2);
ctx.fillStyle = "rgb(" + colorOf(sortedBatchOf[i]).join(",") + ")"; ctx.fillRect(p2.x + 1, p2.y + 1, CELL - 2, CELL - 2);
}
const rc = (b) => Object.entries(reasonCounts(b.breaks)).map(([k, v]) => k + ":" + v).sort().join(" ");
const tb = document.querySelector("#br tbody");
for (const [name, b] of [["作者顺序", nb], ["稳定排序", sb]]) {
const tr = document.createElement("tr");
tr.innerHTML = "<td>" + name + "</td><td class=\\"n\\">" + b.batches.length + "</td><td class=\\"n\\">" + b.breaks.length + "</td><td>" + rc(b) + "</td><td>" + (b.batches.length > CFG.budget.drawCallMax ? "超预算" : "达标") + "</td>";
tb.append(tr);
}
document.getElementById("l1").textContent = "作者顺序 " + nb.batches.length + " 批";
document.getElementById("l2").textContent = "稳定排序 " + sb.batches.length + " 批";
globalThis.__prism = { naiveBatches: nb.batches, sortedBatches: sb.batches, naiveBatchOf, sortedBatchOf,
palette: PALETTE, geom: { cell: CELL, cols: COLS, s1y: S1Y, s2y: S2Y }, drawCallMax: CFG.budget.drawCallMax };
document.title = "prism ta10 batch ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/ta10_batch.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/ta10_batch.html (${Buffer.byteLength(html)} bytes)`);
下面这段是 W 层断言脚本,用无头 Chromium 打开看板,采样 globalThis.__prism 与 Python 落盘的 JSON 对照,再按像素验证「同批同色、跨批换色」:
// file: web/verify_ta10_batch.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_batch.mjs
// 卷二 10.3 W 层断言:无头 Chromium 打开合批看板,采样 globalThis.__prism 与 Python 落盘的
// out/ta10_batch.json 对照;再按像素验证「同批同色、跨批换色」。
import { createRequire } from "node:module";
import { createHash } from "node:crypto";
import { readFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta10_batch.json"), "utf8"));
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/ta10_batch.html")).href;
mkdirSync(resolve(ROOT, "out/shots"), { recursive: true });
let pass = 0, fail = 0;
const check = (n, ok, extra = "") => { ok ? (pass += 1) : (fail += 1); console.log(`${ok ? "pass" : "fail"} ${n}${extra ? " " + extra : ""}`); };
const browser = await chromium.launch({ headless: true });
const warm = await browser.newPage(); // 冷启动预热:首个 page 读 canvas 恒空白
await warm.goto(pageUrl);
await warm.close();
const page = await browser.newPage({ viewport: { width: 1100, height: 600 } });
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(pageUrl);
await page.waitForFunction(() => globalThis.__prism !== undefined, null, { timeout: 15000 });
const s = await page.evaluate(() => globalThis.__prism);
check("无 pageerror", errors.length === 0, errors.join("|"));
check("作者顺序批次划分与 Python 逐位一致", JSON.stringify(s.naiveBatches) === JSON.stringify(py.naive.batches), `${py.naive.batchCount} 批`);
check("稳定排序批次划分与 Python 逐位一致", JSON.stringify(s.sortedBatches) === JSON.stringify(py.sorted.batches), `${py.sorted.batchCount} 批`);
const lines = s.naiveBatches.map((m, i) => `${i}:${m.join(",")}`).sort();
const digest = createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
check("合批 digest 与 Python 逐位一致", digest === py.digest, `${digest}`);
check("预算判定与 Python 一致", (s.naiveBatches.length > s.drawCallMax) === py.naive.overBudget && (s.sortedBatches.length > s.drawCallMax) === py.sorted.overBudget, `${py.drawCallMax}`);
const px = await page.evaluate(() => { const ctx = document.getElementById("strip").getContext("2d"); const g = globalThis.__prism.geom;
const cellAt = (i, y0) => ({ x: (i % g.cols) * g.cell, y: y0 + Math.floor(i / g.cols) * g.cell });
const read = (arr, y0) => arr.map((_, i) => { const p = cellAt(i, y0); const d = ctx.getImageData(p.x + (g.cell >> 1), p.y + (g.cell >> 1), 1, 1).data; return [d[0], d[1], d[2]]; });
return { naive: read(globalThis.__prism.naiveBatchOf, g.s1y), sorted: read(globalThis.__prism.sortedBatchOf, g.s2y),
palette: globalThis.__prism.palette, naiveBatchOf: globalThis.__prism.naiveBatchOf, sortedBatchOf: globalThis.__prism.sortedBatchOf }; });
const colorOk = (pixels, batchOf) => pixels.every((p, i) => JSON.stringify(p) === JSON.stringify(px.palette[batchOf[i] % px.palette.length]));
check("上带每格颜色 = 该元件批次色", colorOk(px.naive, px.naiveBatchOf));
check("下带每格颜色 = 该元件批次色", colorOk(px.sorted, px.sortedBatchOf));
const sameOk = px.naiveBatchOf.every((bi, i) => i === 0 || (bi === px.naiveBatchOf[i - 1]) === (JSON.stringify(px.naive[i]) === JSON.stringify(px.naive[i - 1])));
check("相邻格:同批同色、跨批换色", sameOk);
const drawn = await page.evaluate(() => { const cv = document.getElementById("strip");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data; let n = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n += 1; return n; });
check("canvas 已绘制", drawn >= 1, `${drawn} px`);
const shot = resolve(ROOT, "out/shots/ta10_batch.png");
await page.screenshot({ path: shot, fullPage: true });
await browser.close();
check("截图非空", statSync(shot).size > 0, `${statSync(shot).size} bytes`);
console.log(`ℹ pass ${pass}`);
console.log(`ℹ fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
实跑 node web/build_ta10_batch.mjs 后接 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_batch.mjs:
built web/dist/ta10_batch.html (11097 bytes)
pass 无 pageerror
pass 作者顺序批次划分与 Python 逐位一致 12 批
pass 稳定排序批次划分与 Python 逐位一致 7 批
pass 合批 digest 与 Python 逐位一致 c35e28192d63607c
pass 预算判定与 Python 一致 10
pass 上带每格颜色 = 该元件批次色
pass 下带每格颜色 = 该元件批次色
pass 相邻格:同批同色、跨批换色
pass canvas 已绘制 68400 px
pass 截图非空 32080 bytes
ℹ pass 10
ℹ fail 0
10 项全绿。色带用的是脚本内的整数调色板(12 色循环),像素采样与「该元件所属批次的颜色」一致;canvas 已绘制 只断言「≥ 1 px」:无头 Chromium 冷启动的首个 page 读 canvas 会恒为空白,故脚本先开一个预热页走一遍再关掉。
10.3.5 三条边界
- 合批账只算切换次数,不算 GPU 耗时。批次数是 draw call 的上界代理,真实耗时还取决于填充率、overdraw 与图集大小——需真实环境(引擎 / GPU 分析器),命令行不可复现。
- 排序会改变绘制顺序,遮挡与层级必须另行保证。UI 里「后画的盖住先画的」是常见约定,一旦为了合批重排,就要用显式的层级组兜住,否则会把弹窗排到背景下面。
- 本节不引入图集切分。10.2 多分辨率资源分级
决定发哪些档位,本节只在给定图集下算顺序账;「要不要把
ui_icons拆开」是图集规划问题,不属于合批。
阅读导航:上一节:10.2 多分辨率资源分级 · 下一节:11.1 清单与差异计算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。