3.1 解决了「怎么画」,3.2 解决了「画在哪」,这一节解决「画得动吗」。帧率是轻度小游戏留存的第一道门槛——一个会掉帧的合成游戏,玩家第二次打开的概率会明显下降。站内的《游戏引擎实现原理》 讲的是管线内部的命令编码、多线程提交与 GPU 队列;本节讲的是在既有引擎之上,把一帧的绘制调用压下去这件工程活:合批(batching)与图集(atlas),并用整数模型把「合批到底省多少」算成可对照的数字。
一帧的时间花在哪
一帧的耗时可以拆成三项,全是整数微秒:
帧耗时 = 固定开销 + 每批状态切换成本 × 批次数 + 每精灵顶点成本 × 精灵数
三项里,固定开销(清屏、相机、提交一次命令缓冲)与每精灵顶点成本基本不可压——精灵数是你画的东西,省不掉。真正能压的是中间那项 每批状态切换成本 × 批次数:GPU 每次切换纹理或着色器都要打断流水线,一次 draw call 的固定代价远高于几个顶点。所以渲染优化的第一刀,就是把批次数压下去。
压批次数有两条路:
- 按纹理合批:把同一张纹理上的精灵合并进一次调用。批次数从「精灵数」降到「纹理数」。
- 图集(atlas):把多张小纹理并成一张大图,纹理数降到 1,批次数进一步压到接近 1。
第 4 章会讲图集怎么用 PIL 真的打出来;本节先用一个整数模型,把两条路各自省多少算清楚。
D 层:把收益模型写成配置
下面这份 D 层配置落在 config/e03_batch.json:三个成本参数、单批最大精灵数、图集纹理数,以及四个要对照的场景。
{
"version": 1,
"game": "merge-island",
"note": "渲染合批收益模型:一帧耗时 = 固定开销 + 每批状态切换成本 × 批次数 + 每精灵顶点成本 × 精灵数。整数微秒递推。批次数由「纹理数」与「单批最大精灵数」共同决定;图集把纹理数压到 1。全部整数,禁浮点。",
"fixedMicros": 3000,
"perDrawCallMicros": 45,
"perSpriteMicros": 2,
"maxBatchSprites": 64,
"atlasTextures": 1,
"benchBatchSizes": [2, 4, 8, 16, 64],
"scenes": [
{ "id": "hud", "label": "HUD/UI", "textures": 12, "sprites": 60 },
{ "id": "board", "label": "棋盘", "textures": 6, "sprites": 48 },
{ "id": "fx", "label": "特效", "textures": 9, "sprites": 120 },
{ "id": "full", "label": "整屏合计", "textures": 27, "sprites": 228 }
]
}
perDrawCallMicros = 45 是「一次状态切换约等于几个精灵」的关键假设:它决定了合批的性价比。maxBatchSprites = 64 是单批能塞下的最大精灵数(顶点缓冲上限的简化),它是 B 层要扫的旋钮。
S 层:合批收益模型
下面这段是 S 层仿真脚本 sim/e03_batch.py。它读 config/,对每个场景算出三种策略的批次数与帧耗时,落 CSV 与一张纯 Python SVG。
#!/usr/bin/env python3
# file: sim/e03_batch.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e03_batch.py --config config/ --out out/
"""渲染合批收益模型:把一帧的绘制调用数换算成帧耗时,全程整数微秒。
batches(sprites, textures, maxBatch)
per = ceil(sprites / textures) 每个纹理组平均精灵数(上取整均分)
perGroup = ceil(per / maxBatch) 单组超过单批上限时要再切
batches = textures * perGroup
frameMicros(batches, sprites)
= fixedMicros + perDrawCallMicros * batches + perSpriteMicros * sprites
三种策略:未合批(每精灵一次调用)/ 按纹理合批 / 图集(纹理压到 1)。
单批上限 maxBatchSprites 越小,同组纹理越要拆批,合批收益越打折——正是 B 层要扫的敏感性。
产出:out/e03_batch.json + out/e03_batch.csv + out/e03_batch.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, "e03_batch.json"), encoding="utf-8") as f:
return json.load(f)
def batches(sprites, textures, max_batch):
"""一帧需要提交的批次数;上取整用整数式。"""
if sprites <= 0 or textures <= 0:
return 0
per = (sprites + textures - 1) // textures
return textures * ((per + max_batch - 1) // max_batch)
def frame_micros(batch_count, sprites, cfg):
"""一帧耗时(微秒):固定开销 + 状态切换 + 顶点提交。整数。"""
return cfg["fixedMicros"] + cfg["perDrawCallMicros"] * batch_count + cfg["perSpriteMicros"] * sprites
def scene_row(scene, cfg):
sp, tx, mb = scene["sprites"], scene["textures"], cfg["maxBatchSprites"]
calls_batch = batches(sp, tx, mb)
calls_atlas = batches(sp, cfg["atlasTextures"], mb)
m_no = frame_micros(sp, sp, cfg)
m_batch = frame_micros(calls_batch, sp, cfg)
m_atlas = frame_micros(calls_atlas, sp, cfg)
saved = m_no - m_batch
return {"id": scene["id"], "label": scene["label"], "sprites": sp, "textures": tx,
"callsNoBatch": sp, "callsBatch": calls_batch, "callsAtlas": calls_atlas,
"microsNoBatch": m_no, "microsBatch": m_batch, "microsAtlas": m_atlas,
"savedMicros": saved, "savedPct": (saved * 100) // m_no if m_no else 0}
def draw_svg(path, rows):
"""分组柱状图:每场景三根柱 = 未合批 / 按纹理合批 / 图集,纵轴帧耗时(微秒)。"""
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 = (
"《合成小岛》一帧耗时:未合批 / 按纹理合批 / 图集(微秒)")
x0, x1, y0, y1 = PAD, W - 24, H - PAD, 54
max_v = max(r["microsNoBatch"] for r in rows) or 1
slot = (x1 - x0) / len(rows)
bar = slot * 0.22
colors = ["#f87171", "#60a5fa", "#34d399"]
keys = ["microsNoBatch", "microsBatch", "microsAtlas"]
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:.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 = [scene_row(s, cfg) for s in cfg["scenes"]]
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "e03_batch.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["scene", "sprites", "textures", "callsNoBatch", "callsBatch", "callsAtlas",
"microsNoBatch", "microsBatch", "microsAtlas", "savedMicros", "savedPct"])
for r in rows:
w.writerow([r["id"], r["sprites"], r["textures"], r["callsNoBatch"], r["callsBatch"], r["callsAtlas"],
r["microsNoBatch"], r["microsBatch"], r["microsAtlas"], r["savedMicros"], r["savedPct"]])
svg_bytes = draw_svg(os.path.join(args.out, "e03_batch.svg"), rows)
full = next(r for r in rows if r["id"] == "full")
report = {"version": cfg["version"], "config": os.path.join(args.config, "e03_batch.json"),
"maxBatchSprites": cfg["maxBatchSprites"], "perDrawCallMicros": cfg["perDrawCallMicros"],
"perSpriteMicros": cfg["perSpriteMicros"], "scenes": rows,
"fullScreen": {k: full[k] for k in ("callsNoBatch", "callsBatch", "callsAtlas", "microsNoBatch",
"microsBatch", "microsAtlas", "savedMicros", "savedPct")},
"svg": os.path.join(args.out, "e03_batch.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "e03_batch.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"maxBatchSprites": cfg["maxBatchSprites"], "fullScreen": report["fullScreen"],
"svgBytes": svg_bytes}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"maxBatchSprites": 64, "fullScreen": {"callsNoBatch": 228, "callsBatch": 27, "callsAtlas": 4, "microsNoBatch": 13716, "microsBatch": 4671, "microsAtlas": 3636, "savedMicros": 9045, "savedPct": 65}, "svgBytes": 2516}
整屏(228 精灵 / 27 纹理)三档对照:未合批 228 次调用、13716 µs;按纹理合批降到 27 次调用、4671 µs,省下 9045 µs(约 65%);再上图集降到 4 次调用、3636 µs。逐场景的 CSV(out/e03_batch.csv)摊开看:
| 场景 | 精灵 | 纹理 | 未合批调用 | 合批调用 | 图集调用 | 未合批 µs | 合批 µs | 图集 µs | 省 µs | 省% |
|---|---|---|---|---|---|---|---|---|---|---|
| HUD/UI | 60 | 12 | 60 | 12 | 1 | 5820 | 3660 | 3165 | 2160 | 37 |
| 棋盘 | 48 | 6 | 48 | 6 | 1 | 5256 | 3366 | 3141 | 1890 | 35 |
| 特效 | 120 | 9 | 120 | 9 | 2 | 8640 | 3645 | 3330 | 4995 | 57 |
| 整屏合计 | 228 | 27 | 228 | 27 | 4 | 13716 | 4671 | 3636 | 9045 | 65 |
读法:省下的比例,取决于「每精灵的调用数」能压到多低。特效场景 120 精灵压到 9 批,省得最多(57%);棋盘只有 48 精灵、纹理又少,绝对收益最小(35%)。图集在合批之上还能再省一截(整屏再省 1035 µs),因为把 27 批进一步压到 4 批——这就是第 4 章要真的打包图集的动机。落盘的 out/e03_batch.svg 是每场景三根柱的分组柱状图,一眼能看出「红柱远高于蓝绿柱」。
B 层:批大小敏感性
S 层假设「一个纹理组正好塞进一批」。但真实的单批有顶点上限,maxBatchSprites 越小,同一组纹理越要拆成多批,收益就越打折。下面这段 B 层基准 sim/e03_batch_bench.py 固定整屏场景,扫 maxBatchSprites,看收益在哪一档趋于平台。
#!/usr/bin/env python3
# file: sim/e03_batch_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/e03_batch_bench.py --config config/ --out out/
"""B 基准:批大小(单批最大精灵数 maxBatchSprites)敏感性。
固定精灵数与纹理数,扫 maxBatchSprites ∈ benchBatchSizes,看「整屏」场景的合批收益
随批大小怎么变、在哪一档趋于平台。软告警:收益保留率低于阈值只打 WARN,恒返回 0。
产出 out/e03_batch_bench.json
"""
from __future__ import annotations
import argparse
import json
import os
from e03_batch import load, batches, frame_micros
WARN_RETENTION_PERMILLE = 900 # 最苛刻批大小下收益保留率低于 90% 就打软告警
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)
full = next(s for s in cfg["scenes"] if s["id"] == "full")
sp, tx = full["sprites"], full["textures"]
m_no = frame_micros(sp, sp, cfg) # 未合批:每精灵一次调用
base_saved = m_no - frame_micros(batches(sp, tx, cfg["maxBatchSprites"]), sp, cfg)
rows = []
for mb in cfg["benchBatchSizes"]:
c2 = dict(cfg)
c2["maxBatchSprites"] = mb
calls_batch = batches(sp, tx, mb)
calls_atlas = batches(sp, cfg["atlasTextures"], mb)
m_batch = frame_micros(calls_batch, sp, c2)
saved = m_no - m_batch
rows.append({"maxBatchSprites": mb, "callsBatch": calls_batch, "callsAtlas": calls_atlas,
"microsBatch": m_batch, "microsAtlas": frame_micros(calls_atlas, sp, c2),
"savedMicros": saved, "savedPct": (saved * 100) // m_no if m_no else 0,
"retentionPermille": (saved * 1000) // base_saved if base_saved else 0})
best = max(r["savedMicros"] for r in rows)
worst = min(r["savedMicros"] for r in rows)
drift_pct = (best - worst) * 100 // best if best else 0
worst_retention = min(r["retentionPermille"] for r in rows)
within = worst_retention >= WARN_RETENTION_PERMILLE
os.makedirs(args.out, exist_ok=True)
report = {"version": cfg["version"], "scene": full["id"], "sprites": sp, "textures": tx,
"microsNoBatch": m_no, "baseBatchSize": cfg["maxBatchSprites"], "baseSavedMicros": base_saved,
"sweep": rows, "bestSavedMicros": best, "worstSavedMicros": worst, "savedDriftPct": drift_pct,
"worstRetentionPermille": worst_retention, "warnThresholdPermille": WARN_RETENTION_PERMILLE,
"withinTolerance": within}
with open(os.path.join(args.out, "e03_batch_bench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"scene": full["id"], "microsNoBatch": m_no, "baseSavedMicros": base_saved,
"bestSavedMicros": best, "worstSavedMicros": worst, "savedDriftPct": drift_pct,
"worstRetentionPermille": worst_retention, "withinTolerance": within}, ensure_ascii=False))
if not within:
print(f"WARN 批大小最苛刻档收益保留率 {worst_retention}‰ < {WARN_RETENTION_PERMILLE}‰")
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出(同机相对量级,绝对数值不可移植):
{"scene": "full", "microsNoBatch": 13716, "baseSavedMicros": 9045, "bestSavedMicros": 9045, "worstSavedMicros": 4185, "savedDriftPct": 53, "worstRetentionPermille": 462, "withinTolerance": false}
WARN 批大小最苛刻档收益保留率 462‰ < 900‰
扫描曲线(maxBatchSprites → 合批调用数 / 省下 µs / 收益保留率):
| 单批上限 | 合批调用 | 省 µs | 收益保留率‰ |
|---|---|---|---|
| 2 | 135 | 4185 | 462 |
| 4 | 81 | 6615 | 731 |
| 8 | 54 | 7830 | 865 |
| 16 | 27 | 9045 | 1000 |
| 64 | 27 | 9045 | 1000 |
读法:整屏每纹理组平均 9 个精灵(228 ÷ 27)。单批上限 ≥ 9 时,一个纹理组正好一批,收益到顶(1000‰);一旦上限掉到 8 以下,同组被迫拆批,收益迅速打折——上限 2 时保留率只剩 462‰,比最优差一半。这就是「批大小敏感性」:合批的收益不是常数,它高度依赖单批能塞多少精灵。曲线在 16 处拐平(9 恰好塞进 16),再往上加没有额外收益。因为最苛刻档保留率低于阈值,脚本打了软告警(WARN)但恒返回 0——B 层只提示偏差,不硬失败。
这一节要记住的三件事
- 先压批次数,再谈别的。一帧耗时里唯一便宜可压的是「每批状态切换成本 × 批次数」,其余两项(固定开销、每精灵顶点成本)基本不动。
- 合批与图集是两步,不是一步。按纹理合批把调用数从「精灵数」降到「纹理数」;图集再把纹理数降到 1。整屏场景两步下来是 228 → 27 → 4。
- 收益对批大小敏感。单批上限低于「每纹理组精灵数」时,收益会腰斩。上线前先量一量目标机型的顶点上限,再定图集切分策略。
必须诚实标注:perDrawCallMicros = 45、perSpriteMicros = 2、fixedMicros = 3000 都是数量级假设,不是任何真机 profiling 量出来的;本节所有数字是「同机相对量级,绝对数值不可移植」。本节能确定的是模型的形状(三项可加、批次数是唯一旋钮、收益有拐点)与相对结论(合批省得最多、图集再省一截、批大小有拐点);真机上的绝对帧耗时需在目标设备上实测,属于「需真实业务环境,命令行不可复现」的范畴。下一章把这套「纹理」真正打成图集:PIL 打包、纹理压缩与体积账。
阅读导航:上一节:多分辨率适配与安全区 · 下一节:图集打包与纹理压缩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。