站内 《移动端与 PC 的性能预算》 与第一卷 7.1 面数与骨骼分级预算 已经给了「按档位定面数 / 骨骼 / 材质槽上限」的通用账,LOD 的几何预算在 《GPU 驱动渲染与 LOD》 那一侧。本节不重推它们,只补低多边形风格独有的一笔账:面数预算和顶点预算在平面着色下不是同一个数,把它们当成一个数会直接漏掉一半预算。
4.1.1 面数预算 ≠ 顶点预算
低多边形几乎总是配平面着色(flat shading):每个面一个法线,相邻面不做平滑。代价是相邻面不能共享顶点——共享了法线就会被平均成平滑着色。于是上传到引擎的顶点数不是「网格的顶点数」,而是「面的顶点数」:
| 口径 | 顶点数 | 用途 |
|---|---|---|
| 索引共享 | 资产作者给出的最小共享顶点数 | 平滑着色、碰撞体、离线统计 |
| 平面着色 | 3 × 面数(每面独立) | 低多边形的默认上传口径 |
两套口径的比值不是固定的:共享度越高,平面着色放大的倍数越大。真正上传的是平面着色那一套,所以顶点预算必须按 3 × 面数 算,否则「面数过线」会被误当成「顶点也过线」。
4.1.2 两套口径的账
把《星屑旅店》lowpoly 主题包的一屏大厅 + 一间客房的 12 件家具记成一份数据:每件给面数与共享顶点数,再给顶点布局步长与整屏上限。这份配置同时是 S 层账本与 P 层校验器的输入。
{
"project": "stardust-inn",
"theme": "lowpoly",
"specVersion": 1,
"note": "4.1 面数与顶点预算:《星屑旅店》lowpoly 主题包的一屏家具(12 件)。平面着色要求每个面独立顶点,故 splitVerts = 3 * faces;索引共享的 sharedVerts 是资产作者给出的最小共享顶点数。顶点布局 stride = position(12)+normal(12)+uv(8) = 32 字节;16 位索引 = 2 字节。sceneBudgetTris / sceneBudgetVerts 是整屏上限。全部整数,跨语言逐位一致。",
"vertexStrideBytes": 32,
"indexBytes": 2,
"sceneBudgetTris": 6000,
"sceneBudgetVerts": 3000,
"assets": [
{ "id": "counter", "cn": "柜台", "faces": 236, "sharedVerts": 122 },
{ "id": "bed", "cn": "床", "faces": 196, "sharedVerts": 102 },
{ "id": "shelf", "cn": "货架", "faces": 168, "sharedVerts": 90 },
{ "id": "plant", "cn": "盆栽", "faces": 140, "sharedVerts": 74 },
{ "id": "table", "cn": "桌子", "faces": 116, "sharedVerts": 60 },
{ "id": "vase", "cn": "花瓶", "faces": 96, "sharedVerts": 50 },
{ "id": "stool", "cn": "凳子", "faces": 88, "sharedVerts": 46 },
{ "id": "lamp", "cn": "落地灯", "faces": 72, "sharedVerts": 38 },
{ "id": "rug", "cn": "地毯", "faces": 32, "sharedVerts": 18 },
{ "id": "clock", "cn": "挂钟", "faces": 24, "sharedVerts": 14 },
{ "id": "candle", "cn": "蜡烛", "faces": 20, "sharedVerts": 11 },
{ "id": "painting", "cn": "挂画", "faces": 12, "sharedVerts": 8 }
]
}
4.1.3 S 层:两套顶点口径的账
下面这段是 S 层脚本,逐件算平面着色顶点数(3 × 面数)与索引共享顶点数,再算两种顶点缓冲字节与整屏合计,最后与面数 / 顶点两个上限对账。全整数、地板除,落 JSON + CSV + SVG:
#!/usr/bin/env python3
# file: sim/st04_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st04_budget.py
"""4.1 面数与顶点预算的 S 层整数账。
读 config/st04_lowpoly.json 的 12 件家具,对每件算两种顶点口径:
平面着色(每面独立顶点,低多边形的默认口径):splitVerts = 3 * faces
索引共享(平滑着色的最小值,作者给出): sharedVerts
再算两种顶点缓冲字节:
平面着色 VB = splitVerts * stride,索引缓冲 0
索引共享 VB = sharedVerts * stride,索引缓冲 faces * 3 * indexBytes
合计后与 sceneBudgetTris / sceneBudgetVerts 对账,并算「平面着色 / 索引共享」的字节倍数
(×100,整数地板除)。产出 out/st04_budget.json + .csv + .svg。全部整数,跨语言逐位一致。
"""
import csv
import hashlib
import json
import os
import sys
import xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG = os.path.join(ROOT, "config/st04_lowpoly.json")
OUT = os.path.join(ROOT, "out")
def per_asset(a, stride, index_bytes):
faces, shared = a["faces"], a["sharedVerts"]
split = 3 * faces
return {
"id": a["id"], "cn": a["cn"], "faces": faces, "sharedVerts": shared,
"splitVerts": split,
"splitVbBytes": split * stride,
"indexedVbBytes": shared * stride,
"indexBytes": faces * 3 * index_bytes,
"indexedTotalBytes": shared * stride + faces * 3 * index_bytes,
}
def write_svg(rows, path):
"""每件家具两根条:索引共享顶点数(浅)与平面着色顶点数(深),条长 = 顶点数。"""
W, H = 460, 24 * len(rows) + 40
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}", "role": "img"})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#1f2933"})
for i, r in enumerate(rows):
y = 12 + i * 24
ET.SubElement(svg, "text", {"x": "8", "y": str(y + 12), "fill": "#eceff1",
"font-family": "monospace", "font-size": "10"}).text = \
f'{r["id"]:<9} shared={r["sharedVerts"]:>4} split={r["splitVerts"]:>4}'
ET.SubElement(svg, "rect", {"x": "250", "y": str(y), "width": str(r["sharedVerts"] * 2),
"height": "8", "fill": "#93c5fd"})
ET.SubElement(svg, "rect", {"x": "250", "y": str(y + 9), "width": str(r["splitVerts"] * 2),
"height": "8", "fill": "#f59e0b"})
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
cfg = json.load(open(CONFIG, encoding="utf-8"))
stride, index_bytes = cfg["vertexStrideBytes"], cfg["indexBytes"]
rows = [per_asset(a, stride, index_bytes) for a in cfg["assets"]]
total_faces = sum(r["faces"] for r in rows)
total_split = sum(r["splitVerts"] for r in rows)
total_shared = sum(r["sharedVerts"] for r in rows)
flat_vb = sum(r["splitVbBytes"] for r in rows)
indexed_vb = sum(r["indexedVbBytes"] for r in rows)
index_total = sum(r["indexBytes"] for r in rows)
indexed_total = indexed_vb + index_total
ratio_x100 = flat_vb * 100 // indexed_total
digest = hashlib.sha256("\n".join(
f'{r["id"]}:{r["faces"]}:{r["splitVerts"]}:{r["sharedVerts"]}' for r in rows).encode("utf-8")).hexdigest()[:16]
report = {
"project": cfg["project"], "theme": cfg["theme"],
"vertexStrideBytes": stride, "indexBytes": index_bytes,
"assetCount": len(rows),
"totalFaces": total_faces, "totalSplitVerts": total_split, "totalSharedVerts": total_shared,
"flatVbBytes": flat_vb, "indexedVbBytes": indexed_vb, "indexTotalBytes": index_total,
"indexedTotalBytes": indexed_total, "bytesRatioX100": ratio_x100,
"sceneBudgetTris": cfg["sceneBudgetTris"], "sceneBudgetVerts": cfg["sceneBudgetVerts"],
"trisOk": total_faces <= cfg["sceneBudgetTris"],
"vertsFlatOk": total_split <= cfg["sceneBudgetVerts"],
"vertsIndexedOk": total_shared <= cfg["sceneBudgetVerts"],
"digest": digest, "assets": rows,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "st04_budget.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "st04_budget.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "faces", "splitVerts", "sharedVerts", "splitVbBytes", "indexedTotalBytes"])
for r in rows:
w.writerow([r["id"], r["faces"], r["splitVerts"], r["sharedVerts"],
r["splitVbBytes"], r["indexedTotalBytes"]])
write_svg(rows, os.path.join(OUT, "st04_budget.svg"))
print(f'assets={len(rows)} stride={stride} indexBytes={index_bytes}')
for r in rows:
print(f' {r["id"]:<9} faces={r["faces"]:>4} splitVerts={r["splitVerts"]:>4} '
f'sharedVerts={r["sharedVerts"]:>4} splitVB={r["splitVbBytes"]:>7}')
print(f'totalFaces={total_faces} totalSplitVerts={total_split} totalSharedVerts={total_shared}')
print(f'flatVbBytes={flat_vb} indexedTotalBytes={indexed_total} bytesRatioX100={ratio_x100}')
print(f'trisOk={report["trisOk"]} vertsFlatOk={report["vertsFlatOk"]} vertsIndexedOk={report["vertsIndexedOk"]}')
print(f'digest={digest}')
print("wrote out/st04_budget.json out/st04_budget.csv out/st04_budget.svg")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st04_budget.py(同机相对量级,绝对数值不可移植):
assets=12 stride=32 indexBytes=2
counter faces= 236 splitVerts= 708 sharedVerts= 122 splitVB= 22656
bed faces= 196 splitVerts= 588 sharedVerts= 102 splitVB= 18816
shelf faces= 168 splitVerts= 504 sharedVerts= 90 splitVB= 16128
plant faces= 140 splitVerts= 420 sharedVerts= 74 splitVB= 13440
table faces= 116 splitVerts= 348 sharedVerts= 60 splitVB= 11136
vase faces= 96 splitVerts= 288 sharedVerts= 50 splitVB= 9216
stool faces= 88 splitVerts= 264 sharedVerts= 46 splitVB= 8448
lamp faces= 72 splitVerts= 216 sharedVerts= 38 splitVB= 6912
rug faces= 32 splitVerts= 96 sharedVerts= 18 splitVB= 3072
clock faces= 24 splitVerts= 72 sharedVerts= 14 splitVB= 2304
candle faces= 20 splitVerts= 60 sharedVerts= 11 splitVB= 1920
painting faces= 12 splitVerts= 36 sharedVerts= 8 splitVB= 1152
totalFaces=1200 totalSplitVerts=3600 totalSharedVerts=633
flatVbBytes=115200 indexedTotalBytes=27456 bytesRatioX100=419
trisOk=True vertsFlatOk=False vertsIndexedOk=True
digest=1ce7585bc5847eaf
wrote out/st04_budget.json out/st04_budget.csv out/st04_budget.svg
4.1.4 P 层:同一套口径的 TS 内核
下面这段是 P 层 TS 内核,与 Python 逐条对应:同一个 3 × faces、同一个地板除字节倍数、同一份 digest 行格式。
// file: core/src/st04/budget.ts
// tier: P
// verify: node --test 'core/test/st04_*.test.ts'
// 4.1 面数与顶点预算的 P 层内核。与 sim/st04_budget.py 同一套整数口径:
// 平面着色 splitVerts = 3 * faces(每面独立顶点);索引共享 VB = sharedVerts * stride + faces * 3 * indexBytes;
// 字节倍数 = flatVbBytes * 100 // indexedTotalBytes(整数地板除)。整数类,跨语言逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Asset = { id: string; cn: string; faces: number; sharedVerts: number };
export type SceneTotals = {
totalFaces: number;
totalSplit: number;
totalShared: number;
flatVbBytes: number;
indexedTotalBytes: number;
};
/** 平面着色顶点数:每面独立顶点,一个三角形 3 个。 */
export function splitVerts(faces: number): number {
return 3 * faces;
}
/** 平面着色顶点缓冲字节:非索引,直接 3 面 × 顶点字节。 */
export function splitVbBytes(faces: number, stride: number): number {
return 3 * faces * stride;
}
/** 索引共享总字节:顶点缓冲 + 索引缓冲。 */
export function indexedTotalBytes(faces: number, sharedVerts: number, stride: number, indexBytes: number): number {
return sharedVerts * stride + faces * 3 * indexBytes;
}
/** 整屏合计:全整数加总。 */
export function sceneTotals(assets: Asset[], stride: number, indexBytes: number): SceneTotals {
let totalFaces = 0;
let totalSplit = 0;
let totalShared = 0;
let flatVb = 0;
let indexedTotal = 0;
for (const a of assets) {
totalFaces += a.faces;
totalSplit += splitVerts(a.faces);
totalShared += a.sharedVerts;
flatVb += splitVbBytes(a.faces, stride);
indexedTotal += indexedTotalBytes(a.faces, a.sharedVerts, stride, indexBytes);
}
return { totalFaces, totalSplit, totalShared, flatVbBytes: flatVb, indexedTotalBytes: indexedTotal };
}
/** 平面着色 / 索引共享 的字节倍数 ×100,整数地板除(禁用浮点 round)。 */
export function bytesRatioX100(flatVbBytes: number, indexedTotalBytes: number): number {
return Math.floor((flatVbBytes * 100) / indexedTotalBytes);
}
/** digest 行:id:faces:splitVerts:sharedVerts,与 Python 同序同格式。 */
export function budgetLines(assets: Asset[]): string[] {
return assets.map((a) => `${a.id}:${a.faces}:${splitVerts(a.faces)}:${a.sharedVerts}`);
}
4.1.5 跨语言对照
P 层测试 core/test/st04_budget.test.ts 读 Python 落盘的 out/st04_budget.json,断言合计与 digest 逐位一致,并用两条负例守住口径。实跑 node --test 'core/test/st04_*.test.ts'(本节 5 条;本章三节合计 13 条):
budget digest = 1ce7585bc5847eaf
✔ 跨语言:顶点数、字节账、字节倍数 == Python 产物(逐位一致)
✔ 跨语言:digest 逐位一致
✔ 正例:面数过线,平面着色顶点数爆线,索引共享顶点数过线
✔ 负例:忘记「平面着色每面独立顶点」会让顶点预算假过线
✔ 负例:字节倍数用 Math.round 会与整数地板除分岔
ℹ pass 13
ℹ fail 0
两侧 digest 都是 1ce7585bc5847eaf,顶点数与字节账全是整数,逐位一致。两条负例各守一个方向:忘记 ×3 会让 3600 被误报成 633(假过线),用 Math.round 会把字节倍数从 419 抬成 420。
4.1.6 读表:面数过线不等于顶点过线
| 口径 | 数值 | 上限 | 判定 |
|---|---|---|---|
| 整屏面数 | 1200 | 6000 | 通过 |
| 平面着色顶点(3 × 面数) | 3600 | 3000 | 爆线 |
| 索引共享顶点 | 633 | 3000 | 通过 |
| 顶点缓冲字节(平面 / 索引共享) | 115200 / 27456 | — | 倍数 419(×100) |
三个结论值得记下:
- 同一屏家具,面数只用了上限的 20%,顶点却超了 20%。 低多边形的瓶颈经常不在面数而在顶点,因为平面着色把顶点放大了 3 倍(相对共享网格是 5.7 倍)。
- 索引共享那一栏不能拿来做上传预算。 633 是「如果允许平滑着色」的理论最小值,低多边形用不上;把它当上传顶点数会漏掉 82% 的顶点。
- 字节倍数 419 是整数地板除的结果,不是 4.19 的浮点值——同一个数在 Python 与 TypeScript 两侧逐位相同,换浮点就会在边界分岔(负例二)。
对《星屑旅店》的直接结论:lowpoly 主题的整屏顶点预算要按平面着色口径定,或者规定「每件资产的 3 × 面数 ≤ 单件顶点上限」。真实的顶点上传、剔除与批次合并由渲染器完成,命令行不可复现(X);本节只做美术侧可复算的整数账。
阅读导航:上一节:3.3 X:画质与笔触 · 下一节:4.2 平面着色与色块划分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。