站内的 Cocos 游戏开发实战 那 55 篇讲的是在 Cocos Creator 里怎么配图集、怎么用它的合批设置,那是产品交付工程;本节讲的是与引擎无关的 UI 合批结构原理:绘制调用从哪来、什么会打断合批、元素数与帧占比是什么关系——这套结构换成任何引擎都成立。第 3.3 节讲的是场景渲染(棋盘、特效)的合批,本节把镜头对准 UI:界面本身也会吃掉可观的帧预算,而它的成本结构和一个几百行的列表完全不同。
UI 的绘制调用是怎么来的
渲染一帧的代价可以粗分成三段:一次固定开销(清屏、提交),每次绘制调用的状态切换成本,以及每个元素的顶点提交成本。前两项跟「你画了多少个框」无关,只跟「你提交了多少次绘制」有关。所以 UI 性能的第一性问题不是「元素多不多」,而是「这些元素能不能合并成少数几次绘制」。
合并的条件很朴素:同一张纹理 + 同一种材质的元素可以并进同一批。图集(把很多小图打进一张大图)之所以重要,就是它把「很多张纹理」压成「一张纹理」,让本来自动分家的元素重新变成一批。
什么会打断合批
关键机制在这里:合批是按绘制顺序扫过去的,一旦相邻两个元素的纹理或材质不同,就必须打断、另起一批。于是 UI 里最常见的坏味道出现了——图标和文字交替摆放:图标(图集A)→ 文字(字体图集)→ 图标(图集A)→ 文字…… 每交替一次就断一次批,元素越多、断得越碎。
这解释了一个反直觉的现象:两个界面元素总数一样,一个 3 次绘制、一个 200 次绘制,差别不在元素多少,而在作者顺序有没有把同键元素排在一起。把同纹理同材质的元素归并到一起(或交给引擎的自动排序),是 UI 合批最直接的收益来源。
收益模型
本节用一个整数递推模型把这件事算清楚:
- 作者顺序(naive):相邻同键先并段,每段至少一次调用,段内再按单批上限切分。
- 排序合并(optimal):同键元素全部归并后,各自按上限切分。
帧耗时 = 固定开销 + 每绘制调用成本 × 调用数 + 每元素顶点成本 × 元素数,UI 帧占比 = 帧耗时 ÷ 帧预算(60 fps 下 16670 微秒)。全部整数,禁浮点。
D 层:把模型写成配置
下面这份 D 层配置落在 config/e11_uibatch.json:帧预算、三段成本、单批上限、告警阈值,以及四个界面(HUD、商店、棋盘、整屏合计)的元素分组。每组只声明「纹理 + 材质 + 数量」,顺序就是作者顺序。
{
"version": 1,
"game": "merge-island",
"note": "UI 合批收益模型:UI 元素按「纹理 + 材质」合并绘制调用;作者顺序里相邻元素纹理/材质不同就打断合批。帧耗时 = 固定开销 + 每绘制调用成本 × 调用数 + 每元素顶点成本 × 元素数。整数微秒,禁浮点。",
"frameBudgetMicros": 16670,
"fixedMicros": 2500,
"perDrawCallMicros": 150,
"perElementMicros": 2,
"maxBatchElements": 128,
"uiShareWarnPermille": 300,
"benchRunLength": 2,
"benchElementCounts": [24, 48, 96, 192, 384],
"screens": [
{ "id": "hud", "label": "主界面 HUD", "groups": [
{ "tex": "panel", "mat": "ui", "count": 12 }, { "tex": "icon", "mat": "ui", "count": 18 },
{ "tex": "panel", "mat": "ui", "count": 9 }, { "tex": "text", "mat": "font", "count": 24 } ] },
{ "id": "shop", "label": "商店页", "groups": [
{ "tex": "panel", "mat": "ui", "count": 6 }, { "tex": "card", "mat": "ui", "count": 30 },
{ "tex": "card", "mat": "ui", "count": 18 }, { "tex": "btn", "mat": "ui", "count": 12 },
{ "tex": "text", "mat": "font", "count": 40 } ] },
{ "id": "board", "label": "棋盘(单纹理)", "groups": [ { "tex": "tile", "mat": "ui", "count": 300 } ] },
{ "id": "full", "label": "整屏合计", "groups": [
{ "tex": "panel", "mat": "ui", "count": 40 }, { "tex": "icon", "mat": "ui", "count": 60 },
{ "tex": "panel", "mat": "ui", "count": 30 }, { "tex": "card", "mat": "ui", "count": 90 },
{ "tex": "card", "mat": "ui", "count": 20 }, { "tex": "btn", "mat": "ui", "count": 16 },
{ "tex": "text", "mat": "font", "count": 70 } ] }
]
}
看 HUD 的分组就明白问题所在:panel 12 → icon 18 → panel 9 → text 24,同一个 panel 被 icon 和 text 拆成了两段——这是真实界面里最常见的写法,也正是合批的敌人。board 只有一种纹理,无论如何都是一批多一点,反而最省。
S 层:收益模型脚本
下面这段是 S 层仿真脚本 sim/e11_uibatch.py。它读上面那份配置,对每个界面算出作者顺序与排序合并两条路径的绘制调用数与帧耗时,产出 JSON、CSV 和一张纯 Python SVG 分组柱状图。
#!/usr/bin/env python3
# file: sim/e11_uibatch.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e11_uibatch.py --config config/ --out out/
"""UI 合批收益模型:UI 元素按「纹理 + 材质」合并绘制调用,全程整数。
作者顺序(naive):相邻同键先并段,再各自按单批上限切 → calls = Σ ceil(count / maxBatch)
排序合并(optimal):同键元素归并后各自按上限切 → calls = Σ_键 ceil(该键总数 / maxBatch)
帧耗时(微秒)= fixedMicros + perDrawCallMicros × 调用数 + perElementMicros × 元素数
UI 帧占比 = 帧耗时 × 1000 / frameBudgetMicros(千分比整数)。
产出:out/e11_uibatch.json + out/e11_uibatch.csv + out/e11_uibatch.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "e11_uibatch.json"), encoding="utf-8") as f:
return json.load(f)
def ceil_div(a, b):
"""整数上取整,避免浮点 ceil。"""
return (a + b - 1) // b
def collapse_adjacent(groups):
"""把相邻且同(纹理,材质)的组并成一段,得到作者顺序下的「运行段」。"""
runs = []
for g in groups:
key = (g["tex"], g["mat"])
if runs and runs[-1][0] == key:
runs[-1][1] += g["count"]
else:
runs.append([key, g["count"]])
return runs
def naive_calls(groups, max_batch):
"""作者顺序:相邻不同键即打断合批,每段至少一次调用,段内再按上限切。"""
return sum(ceil_div(cnt, max_batch) for _key, cnt in collapse_adjacent(groups))
def optimal_calls(groups, max_batch):
"""排序合并:同键元素归并后各自按上限切。"""
totals = {}
for g in groups:
totals[(g["tex"], g["mat"])] = totals.get((g["tex"], g["mat"]), 0) + g["count"]
return sum(ceil_div(t, max_batch) for t in totals.values())
def frame_micros(calls, elements, cfg):
"""一帧 UI 耗时(微秒):固定开销 + 每调用状态切换 + 每元素顶点提交。整数。"""
return cfg["fixedMicros"] + cfg["perDrawCallMicros"] * calls + cfg["perElementMicros"] * elements
def screen_row(screen, cfg):
mb, groups = cfg["maxBatchElements"], screen["groups"]
elements = sum(g["count"] for g in groups)
cn, co = naive_calls(groups, mb), optimal_calls(groups, mb)
mn, mo = frame_micros(cn, elements, cfg), frame_micros(co, elements, cfg)
return {"id": screen["id"], "label": screen["label"], "elements": elements,
"runs": len(collapse_adjacent(groups)), "keys": len({(g["tex"], g["mat"]) for g in groups}),
"callsNaive": cn, "callsOptimal": co, "microsNaive": mn, "microsOptimal": mo,
"uiSharePermilleNaive": (mn * 1000) // cfg["frameBudgetMicros"],
"savedMicros": mn - mo, "savedPct": ((mn - mo) * 100) // mn if mn else 0}
def draw_svg(path, rows, cfg):
"""分组柱状图:每场景两根柱 = 作者顺序 / 排序合并的绘制调用数。"""
W, H, PAD = 780, 440, 64
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
"《合成小岛》UI 绘制调用数:作者顺序 vs 按纹理/材质排序合并"
x0, x1, y0, y1 = PAD, W - 24, H - PAD, 54
max_v = max(r["callsNaive"] for r in rows) or 1
slot, bar, colors, keys = (x1 - x0) / len(rows), (x1 - x0) / len(rows) * 0.26, ["#f87171", "#34d399"], ["callsNaive", "callsOptimal"]
for i in range(5):
y = y1 + (y0 - y1) * i / 4
ET.SubElement(svg, "line", {"x1": str(x0), "y1": f"{y:.1f}", "x2": str(x1), "y2": f"{y:.1f}", "stroke": "#e5e7eb"})
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x1), "y2": str(y0), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x0), "y2": str(y0), "stroke": "#9ca3af"})
for i, r in enumerate(rows):
base = x0 + slot * i + slot * 0.5
for j, k in enumerate(keys):
h = (y0 - y1) * r[k] / max_v
ET.SubElement(svg, "rect", {"x": f"{base + (j - 1) * bar - bar / 2:.1f}", "y": f"{y0 - h:.1f}",
"width": f"{bar:.1f}", "height": f"{h:.1f}", "fill": colors[j]})
ET.SubElement(svg, "text", {"x": f"{base + (j - 1) * bar:.1f}", "y": f"{y0 - h - 4:.1f}", "font-size": "10",
"text-anchor": "middle", "fill": "#374151"}).text = str(r[k])
ET.SubElement(svg, "text", {"x": f"{base:.1f}", "y": str(y0 + 16), "font-size": "11", "text-anchor": "middle", "fill": "#374151"}).text = r["label"]
for j, name in enumerate(["作者顺序", "排序合并"]):
lx = x0 + j * 150
ET.SubElement(svg, "rect", {"x": str(lx), "y": "36", "width": "12", "height": "12", "fill": colors[j]})
ET.SubElement(svg, "text", {"x": str(lx + 18), "y": "47", "font-size": "12", "fill": "#374151"}).text = name
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
rows = [screen_row(s, cfg) for s in cfg["screens"]]
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "e11_uibatch.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["screen", "elements", "runs", "keys", "callsNaive", "callsOptimal",
"microsNaive", "microsOptimal", "uiSharePermilleNaive", "savedMicros", "savedPct"])
for r in rows:
w.writerow([r["id"], r["elements"], r["runs"], r["keys"], r["callsNaive"], r["callsOptimal"],
r["microsNaive"], r["microsOptimal"], r["uiSharePermilleNaive"], r["savedMicros"], r["savedPct"]])
svg_bytes = draw_svg(os.path.join(args.out, "e11_uibatch.svg"), rows, cfg)
full = next(r for r in rows if r["id"] == "full")
report = {"version": cfg["version"], "frameBudgetMicros": cfg["frameBudgetMicros"], "maxBatchElements": cfg["maxBatchElements"],
"perDrawCallMicros": cfg["perDrawCallMicros"], "perElementMicros": cfg["perElementMicros"], "screens": rows,
"fullScreen": {k: full[k] for k in ("elements", "callsNaive", "callsOptimal", "microsNaive", "microsOptimal",
"uiSharePermilleNaive", "savedMicros", "savedPct")},
"svg": os.path.join(args.out, "e11_uibatch.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "e11_uibatch.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"frameBudgetMicros": cfg["frameBudgetMicros"], "maxBatchElements": cfg["maxBatchElements"],
"fullScreen": report["fullScreen"], "svgBytes": svg_bytes}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑 python3 sim/e11_uibatch.py --config config/ --out out/(同机相对量级,绝对数值不可移植),四个界面的结果如下:
| 界面 | 元素 | 段数 | 键数 | 调用(作者) | 调用(合并) | 帧耗时(作者) | 帧耗时(合并) | UI 占比‰ |
|---|---|---|---|---|---|---|---|---|
| 主界面 HUD | 63 | 4 | 3 | 4 | 3 | 3226 | 3076 | 193 |
| 商店页 | 106 | 4 | 4 | 4 | 4 | 3312 | 3312 | 198 |
| 棋盘(单纹理) | 300 | 1 | 1 | 3 | 3 | 3550 | 3550 | 212 |
| 整屏合计 | 326 | 6 | 5 | 6 | 5 | 4052 | 3902 | 243 |
读法:HUD 的 4 段里有 3 个键,合并后少一批,省 150 微秒;商店页与棋盘恰好「段数 = 键数」,合并没有收益(商店页的相邻 card 本来就连着、棋盘的 300 个方块同键);整屏合计 6 段 5 键,合并省 150 微秒。这一档的收益看着不大,因为作者顺序本身还没有被拆得太碎——真正拉开差距的是下一节的元素数扫描。
B 层:元素数敏感性
下面这段是 B 层基准 sim/e11_uibatch_bench.py。它构造「两种纹理按 benchRunLength 交替」的 UI,扫元素数,看作者顺序的绘制调用数与 UI 帧占比怎么涨、在哪一档越过 uiShareWarnPermille 阈值。软告警只打 WARN,恒返回 0。
#!/usr/bin/env python3
# file: sim/e11_uibatch_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e11_uibatch_bench.py --config config/ --out out/
"""B 基准:UI 元素数 → 绘制调用数 → 帧耗时占比的敏感性。
构造「两种纹理按 benchRunLength 交替」的 UI:元素越多,作者顺序被打断越频繁,绘制调用数线性上涨;
排序合并则恒为常数(两种纹理各一批)。扫 benchElementCounts,看 UI 帧占比在哪个元素数越过
uiShareWarnPermille 阈值。软告警只打 WARN,恒返回 0。产出 out/e11_uibatch_bench.json
"""
from __future__ import annotations
import argparse
import json
import os
from e11_uibatch import load, frame_micros, naive_calls, optimal_calls
def make_groups(n, run_length):
"""n 个元素,纹理在 a/b 间每 run_length 个交替一次,作者顺序即被打断。"""
groups, i = [], 0
while i < n:
cnt = min(run_length, n - i)
groups.append({"tex": "a" if (i // run_length) % 2 == 0 else "b", "mat": "ui", "count": cnt})
i += cnt
return groups
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
mb, rl, budget = cfg["maxBatchElements"], cfg["benchRunLength"], cfg["frameBudgetMicros"]
rows = []
for n in cfg["benchElementCounts"]:
groups = make_groups(n, rl)
cn, co = naive_calls(groups, mb), optimal_calls(groups, mb)
mn, mo = frame_micros(cn, n, cfg), frame_micros(co, n, cfg)
rows.append({"elements": n, "callsNaive": cn, "callsOptimal": co, "microsNaive": mn, "microsOptimal": mo,
"uiSharePermilleNaive": (mn * 1000) // budget, "withinBudget": mn <= budget})
worst = max(rows, key=lambda r: r["uiSharePermilleNaive"])
over = [r for r in rows if r["uiSharePermilleNaive"] > cfg["uiShareWarnPermille"]]
os.makedirs(args.out, exist_ok=True)
report = {"version": cfg["version"], "frameBudgetMicros": budget, "maxBatchElements": mb, "benchRunLength": rl,
"uiShareWarnPermille": cfg["uiShareWarnPermille"], "sweep": rows,
"worstUiSharePermille": worst["uiSharePermilleNaive"], "worstElements": worst["elements"],
"firstOverElements": over[0]["elements"] if over else None, "overCount": len(over), "withinTolerance": not over}
with open(os.path.join(args.out, "e11_uibatch_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"frameBudgetMicros": budget, "uiShareWarnPermille": cfg["uiShareWarnPermille"],
"worstUiSharePermille": worst["uiSharePermilleNaive"], "worstElements": worst["elements"],
"firstOverElements": report["firstOverElements"], "overCount": len(over),
"withinTolerance": not over}, ensure_ascii=False))
if over:
print(f"WARN UI 帧占比在 {over[0]['elements']} 个元素起越过 {cfg['uiShareWarnPermille']}‰(作者顺序未合批)")
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
扫描结果(同机相对量级,绝对数值不可移植):
| 元素数 | 调用(作者) | 调用(合并) | 帧耗时(作者) | 帧耗时(合并) | UI 占比‰ |
|---|---|---|---|---|---|
| 24 | 12 | 2 | 4348 | 2848 | 260 |
| 48 | 24 | 2 | 6196 | 2896 | 371 |
| 96 | 48 | 2 | 9892 | 2992 | 593 |
| 192 | 96 | 2 | 17284 | 3184 | 1036 |
| 384 | 192 | 4 | 32068 | 3868 | 1923 |
结论很清楚:作者顺序的绘制调用数随元素数线性上涨(每多两个元素就多断一次批),UI 帧占比从 24 个元素的 260‰ 一路涨到 384 个元素的 1923‰,在 48 个元素处就越过 300‰ 阈值(WARN)。而排序合并把调用数死死压在 2~4 批,帧耗时几乎不随元素数变(2848 → 3868 微秒,涨的全是每元素顶点成本)。这就是为什么「UI 元素排序」在弱机上常常是比「减少元素」更划算的一刀。
这一节要记住的三件事
- UI 成本 = 固定开销 + 每次绘制调用 × 调用数 + 每元素 × 元素数。真正会失控的是中间那一项——调用数。
- 合批按绘制顺序扫,相邻不同键就断。图标与文字交替摆放是最常见的「合批杀手」;把同纹理同材质的元素排到一起,或交给引擎自动排序,是收益最直接的一步。
- B 层的价值是看趋势不是看绝对值。作者顺序随元素数线性上涨、排序合并恒定,这个形状比任何单个数字都更能指导决策;阈值只是软告警,用来提醒该去查界面了。
必须诚实标注:perDrawCallMicros=150、perElementMicros=2、fixedMicros=2500 都是本机取的示意系数,不是任何真实设备量出来的;不同引擎、不同 GPU、不同批大小下这些系数差异很大,模型只能给「同机相对量级」的趋势,绝对数值不可移植。真实设备的绘制调用数与帧耗时需用引擎自带的分析器(如各引擎的 Frame Debugger / Profiler)实测,属于「需真实业务环境,命令行不可复现」。
阅读导航:上一节:弹窗、提示与状态反馈 · 下一节:缓动函数与时间轴 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。