顶点属性的语义、合批与 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 读表与边界
| 布局 | 步长 | 顶点缓冲字节 | 用途 |
|---|---|---|---|
| 位置+法线+UV | 32 | 115200 | 全属性基线 |
| 位置+压缩法线+压缩UV | 20 | 72000 | 省字节 |
| 位置+法线+UV+顶点色 | 36 | 129600 | 顶点色,多色块折单槽 |
| 位置+顶点色 | 16 | 57600 | 极简,法线由导数导出 |
- 顶点色的交换比:每顶点多 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 色阶带与描边参数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。