4.3 低多边形的引擎接入约束

低多边形要进引擎,先过三道约束:顶点属性步长、材质槽数与顶点色。本节把《星屑旅店》一屏 12 件家具的四种顶点布局算成字节账,用顶点色把材质槽从 19 降到 12、清掉 counter 与 bed 两个超槽资产,并给出每顶点 4 字节的代价与跨语言 digest。

顶点属性的语义、合批与 draw call 的引擎实现,是 《网格着色器与下一代管线》 与 《渲染引擎》卷 的地盘,本节不重推。本节只做低多边形侧的三笔账:顶点属性步长、材质槽数与顶点色——它们把 4.1 的顶点数与 4.2 的色块从「资产口径」翻译成「引擎口径」。这三条是低多边形最容易在导入期翻车的地方。

4.3.1 三个约束

低多边形进引擎时,美术侧要交代的其实只有三件事:

约束美术侧的量引擎侧的后果
顶点属性布局每属性几个字节步长 → 顶点缓冲总字节
材质槽一件资产几个槽槽数 → draw call 数
顶点色色块是否写进顶点色多色块折进单槽

顶点色是低多边形的关键杠杆:4.2 里一件资产有 3~5 个色块,若每块一个材质槽就是 3~5 个 draw call;把色块写进顶点色(每顶点 4 字节),整件资产就能用一个材质槽画完。代价是顶点缓冲变大,收益是材质槽归并。这笔交换必须算清楚才敢做。

4.3.2 D 层配置

四种顶点布局(从「全属性」到「位置+顶点色」的极简),加一屏 12 件家具的材质槽与色块数。uploadedVerts 取 4.1 的平面着色顶点数 3600——低多边形一律平面着色、每面独立顶点。

{
  "project": "stardust-inn",
  "theme": "lowpoly",
  "specVersion": 1,
  "note": "4.3 低多边形的引擎接入约束:顶点属性布局(每属性字节)→ stride;材质槽 → draw call;顶点色把多色块压进单材质槽。顶点属性语义与合批原理见 graphics/(外链),本节只做字节与槽位账。uploadedVerts = 4.1 的平面着色顶点数(3 * 总面数 1200 = 3600),因为低多边形一律平面着色、每个面独立顶点。materialSlotsCap=2 为单资产材质槽上限(对应 visual_spec.budget.materialSlotsLow)。全部整数,跨语言逐位一致。",
  "uploadedVerts": 3600,
  "materialSlotsCap": 2,
  "colorAttrBytes": 4,
  "layouts": [
    { "id": "pos_normal_uv", "cn": "位置+法线+UV", "attrs": { "position": 12, "normal": 12, "uv": 8 } },
    { "id": "pos_normal_packed_uv", "cn": "位置+压缩法线+压缩UV", "attrs": { "position": 12, "normal": 4, "uv": 4 } },
    { "id": "pos_normal_uv_color", "cn": "位置+法线+UV+顶点色", "attrs": { "position": 12, "normal": 12, "uv": 8, "color": 4 } },
    { "id": "pos_color", "cn": "位置+顶点色(平面着色,法线由屏幕空间导数导出)", "attrs": { "position": 12, "color": 4 } }
  ],
  "assets": [
    { "id": "counter", "cn": "柜台", "verts": 122, "colorBlocks": 5, "materialSlots": 3 },
    { "id": "bed", "cn": "床", "verts": 102, "colorBlocks": 4, "materialSlots": 3 },
    { "id": "shelf", "cn": "货架", "verts": 90, "colorBlocks": 4, "materialSlots": 2 },
    { "id": "plant", "cn": "盆栽", "verts": 74, "colorBlocks": 3, "materialSlots": 2 },
    { "id": "table", "cn": "桌子", "verts": 60, "colorBlocks": 3, "materialSlots": 1 },
    { "id": "vase", "cn": "花瓶", "verts": 50, "colorBlocks": 3, "materialSlots": 1 },
    { "id": "stool", "cn": "凳子", "verts": 46, "colorBlocks": 3, "materialSlots": 1 },
    { "id": "lamp", "cn": "落地灯", "verts": 38, "colorBlocks": 2, "materialSlots": 2 },
    { "id": "rug", "cn": "地毯", "verts": 18, "colorBlocks": 2, "materialSlots": 1 },
    { "id": "clock", "cn": "挂钟", "verts": 14, "colorBlocks": 2, "materialSlots": 1 },
    { "id": "candle", "cn": "蜡烛", "verts": 11, "colorBlocks": 2, "materialSlots": 1 },
    { "id": "painting", "cn": "挂画", "verts": 8, "colorBlocks": 2, "materialSlots": 1 }
  ]
}

4.3.3 S 层:步长、材质槽与顶点色的账

下面这段是 S 层脚本,算三种账:四种布局的步长与顶点缓冲字节、无顶点色 / 有顶点色两种口径的材质槽(即 draw call)数、以及顶点色的字节代价:

#!/usr/bin/env python3
# file: sim/st04_engine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st04_engine.py
"""4.3 低多边形的引擎接入约束的 S 层整数账。

读 config/st04_engine.json:四种顶点属性布局、一屏 12 件家具的材质槽与色块数。算三本账:

  ① 顶点缓冲字节:stride = 各属性字节之和;VB = uploadedVerts * stride
  ② 材质槽 → draw call:无顶点色时 draw call = 各资产材质槽之和;有顶点色时每资产 1 槽
  ③ 单资产材质槽是否超 materialSlotsCap;顶点色的字节代价(uploadedVerts * colorAttrBytes)

顶点属性的语义、合批与 draw call 的引擎实现见 graphics/ 与《渲染引擎》卷(外链),
本节只做字节与槽位的整数账。产出 out/st04_engine.json + .csv。全部整数,跨语言逐位一致。
"""
import csv
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG = os.path.join(ROOT, "config/st04_engine.json")
OUT = os.path.join(ROOT, "out")


def main() -> int:
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    verts = cfg["uploadedVerts"]
    cap = cfg["materialSlotsCap"]

    layouts = [{"id": l["id"], "cn": l["cn"], "attrs": l["attrs"],
                "strideBytes": sum(l["attrs"].values()),
                "vbBytes": verts * sum(l["attrs"].values())} for l in cfg["layouts"]]

    assets = [{"id": a["id"], "cn": a["cn"], "verts": a["verts"], "colorBlocks": a["colorBlocks"],
               "materialSlots": a["materialSlots"],
               "overCap": a["materialSlots"] > cap,
               "slotsWithVertexColor": 1} for a in cfg["assets"]]

    slots_without = sum(a["materialSlots"] for a in assets)
    slots_with = sum(a["slotsWithVertexColor"] for a in assets)
    violations_without = [a["id"] for a in assets if a["overCap"]]
    violations_with = [a["id"] for a in assets if a["slotsWithVertexColor"] > cap]
    color_cost = verts * cfg["colorAttrBytes"]
    digest = hashlib.sha256("\n".join(
        f'{l["id"]}:{l["strideBytes"]}:{l["vbBytes"]}' for l in layouts).encode("utf-8")).hexdigest()[:16]

    report = {
        "project": cfg["project"], "theme": cfg["theme"],
        "uploadedVerts": verts, "materialSlotsCap": cap, "colorAttrBytes": cfg["colorAttrBytes"],
        "assetCount": len(assets), "layouts": layouts,
        "slotsWithoutVertexColor": slots_without, "slotsWithVertexColor": slots_with,
        "savedDrawCalls": slots_without - slots_with,
        "violationsWithoutVertexColor": violations_without,
        "violationsWithVertexColor": violations_with,
        "colorAttrCostBytes": color_cost,
        "minStrideBytes": min(l["strideBytes"] for l in layouts),
        "digest": digest, "assets": assets,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st04_engine.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_engine.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "strideBytes", "vbBytes"])
        for l in layouts:
            w.writerow([l["id"], l["strideBytes"], l["vbBytes"]])

    print(f'uploadedVerts={verts} cap={cap} colorAttrBytes={cfg["colorAttrBytes"]}')
    for l in layouts:
        print(f'  {l["id"]:<22} stride={l["strideBytes"]:>3} vbBytes={l["vbBytes"]:>7}')
    print(f'slotsWithoutVertexColor={slots_without} slotsWithVertexColor={slots_with} '
          f'savedDrawCalls={report["savedDrawCalls"]}')
    print(f'violationsWithoutVertexColor={violations_without} violationsWithVertexColor={violations_with}')
    print(f'colorAttrCostBytes={color_cost} minStrideBytes={report["minStrideBytes"]}')
    print(f'digest={digest}')
    print("wrote out/st04_engine.json out/st04_engine.csv")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/st04_engine.py:

uploadedVerts=3600 cap=2 colorAttrBytes=4
  pos_normal_uv          stride= 32 vbBytes= 115200
  pos_normal_packed_uv   stride= 20 vbBytes=  72000
  pos_normal_uv_color    stride= 36 vbBytes= 129600
  pos_color              stride= 16 vbBytes=  57600
slotsWithoutVertexColor=19 slotsWithVertexColor=12 savedDrawCalls=7
violationsWithoutVertexColor=['counter', 'bed'] violationsWithVertexColor=[]
colorAttrCostBytes=14400 minStrideBytes=16
digest=d8a4e4c68dbb2bea
wrote out/st04_engine.json out/st04_engine.csv

4.3.4 P 层:同一套约束口径的 TS 内核

下面这段是 P 层 TS 内核,与 Python 逐条对应——同一个步长求和、同一个材质槽归并、同一份 digest 行格式:

// file: core/src/st04/engine.ts
// tier: P
// verify: node --test 'core/test/st04_*.test.ts'
// 4.3 低多边形引擎接入约束的 P 层内核。与 sim/st04_engine.py 同一套整数口径:
// stride = 各属性字节之和;VB = uploadedVerts * stride;无顶点色时 draw call = Σ 材质槽,
// 有顶点色时每资产 1 槽。顶点属性语义与合批实现见 graphics/ 与《渲染引擎》卷(外链)。
// 整数类,跨语言逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Layout = { id: string; cn: string; attrs: Record<string, number> };
export type EngineAsset = {
  id: string; cn: string; verts: number; colorBlocks: number; materialSlots: number;
};

/** 顶点属性步长:各属性字节之和。 */
export function strideBytes(layout: Layout): number {
  let sum = 0;
  for (const k of Object.keys(layout.attrs)) sum += layout.attrs[k];
  return sum;
}

/** 顶点缓冲总字节 = 上传顶点数 × 步长。 */
export function vbBytes(layout: Layout, verts: number): number {
  return verts * strideBytes(layout);
}

/** 不带顶点色:draw call = 各资产材质槽之和。 */
export function slotsWithoutVertexColor(assets: EngineAsset[]): number {
  return assets.reduce((n, a) => n + a.materialSlots, 0);
}

/** 带顶点色:色块折进顶点色,每资产 1 个材质槽。 */
export function slotsWithVertexColor(assets: EngineAsset[]): number {
  return assets.length;
}

/** 单资产材质槽超上限的资产清单。 */
export function slotViolations(assets: EngineAsset[], cap: number, useVertexColor: boolean): string[] {
  return assets
    .filter((a) => (useVertexColor ? 1 : a.materialSlots) > cap)
    .map((a) => a.id);
}

/** digest 行:布局 id:stride:vbBytes,与 Python 同序同格式。 */
export function layoutLines(layouts: Layout[], verts: number): string[] {
  return layouts.map((l) => `${l.id}:${strideBytes(l)}:${vbBytes(l, verts)}`);
}

4.3.5 跨语言对照

P 层测试 core/test/st04_engine.test.ts 读 out/st04_engine.json,断言步长、VB 字节、材质槽与 digest 逐位一致。实跑 node --test 'core/test/st04_*.test.ts'(本节 4 条;本章三节合计 13 条):

engine digest = d8a4e4c68dbb2bea
✔ 跨语言:stride、VB 字节 == Python 产物(逐位一致)
✔ 跨语言:材质槽 / draw call 与 digest 逐位一致
✔ 正例:顶点色把 draw call 19→12,违例清零
✔ 负例:不带顶点色时超槽资产必须被点出,不能只看总数
ℹ pass 13
ℹ fail 0

两侧 digest 都是 d8a4e4c68dbb2bea。负例专门守「只看总数会漏」:19 个槽的总数看不出问题,真正超上限的是 counter(3) 与 bed(3) 两件,它们的色块数(5 与 4)都多于槽上限 2——这正是顶点色的用武之地。

4.3.6 读表与边界

布局步长顶点缓冲字节用途
位置+法线+UV32115200全属性基线
位置+压缩法线+压缩UV2072000省字节
位置+法线+UV+顶点色36129600顶点色,多色块折单槽
位置+顶点色1657600极简,法线由导数导出
  • 顶点色的交换比:每顶点多 4 字节(整屏 14400 字节),换来材质槽从 19 降到 12、draw call 少 7 次,并把两件超槽资产清零。字节是线性的,draw call 是每帧成本——这笔账在低多边形这种小网格上几乎稳赚。
  • 极简布局 16 字节是低多边形的下界:只要位置 + 顶点色,法线交给屏幕空间导数。它比全属性布局省一半顶点缓冲。
  • 材质槽上限是 2(来自 visual_spec.budget.materialSlotsLow)。不带顶点色时 counter 与 bed 直接违规,导入期就该被拦下。
  • 本节不含真渲染与真引擎(标 X):顶点上传、属性绑定、批次合并由渲染器完成,命令行不可复现;本节只做美术侧可复算的字节与槽位账,绝对数值不可移植。

4.1 算面与顶点,4.2 分色块,本节把三者收进引擎的约束里。下一章换一种风格:NPR / 卡通渲染——色阶带与描边参数。

阅读导航:上一节:4.2 平面着色与色块划分 · 下一节:5.1 色阶带与描边参数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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