4.1 面数与顶点预算

低多边形的面数预算好算,顶点预算却常被算错:平面着色要求每个面独立顶点,上传顶点数不是共享顶点数,而是面数的 3 倍。本节把《星屑旅店》lowpoly 主题的一屏 12 件家具算成两套账——面数 1200 过线、平面着色顶点 3600 爆线、索引共享 633 过线,并给出字节倍数与跨语言 digest。

站内 《移动端与 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 读表:面数过线不等于顶点过线

口径数值上限判定
整屏面数12006000通过
平面着色顶点(3 × 面数)36003000爆线
索引共享顶点6333000通过
顶点缓冲字节(平面 / 索引共享)115200 / 27456—倍数 419(×100)

三个结论值得记下:

  • 同一屏家具,面数只用了上限的 20%,顶点却超了 20%。 低多边形的瓶颈经常不在面数而在顶点,因为平面着色把顶点放大了 3 倍(相对共享网格是 5.7 倍)。
  • 索引共享那一栏不能拿来做上传预算。 633 是「如果允许平滑着色」的理论最小值,低多边形用不上;把它当上传顶点数会漏掉 82% 的顶点。
  • 字节倍数 419 是整数地板除的结果,不是 4.19 的浮点值——同一个数在 Python 与 TypeScript 两侧逐位相同,换浮点就会在边界分岔(负例二)。

对《星屑旅店》的直接结论:lowpoly 主题的整屏顶点预算要按平面着色口径定,或者规定「每件资产的 3 × 面数 ≤ 单件顶点上限」。真实的顶点上传、剔除与批次合并由渲染器完成,命令行不可复现(X);本节只做美术侧可复算的整数账。

阅读导航:上一节:3.3 X:画质与笔触 · 下一节:4.2 平面着色与色块划分 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则