6.2 材质分区与细节层级
上一节解决了「一张材质要几张图」。本节解决另一半:一个道具组到底该切几张材质。站内 渲染原理专题 讲采样与合批原理,本节不重推;这里只做规格与账。
分区决定槽位,层级决定字节
「材质分区」是物理材质的划分:木头、金属、布、玻璃、陶瓷、皮革、石头各自一套反射参数,切错分区就会让木头和金属共用一张贴图,写实的可信度立刻塌。分区数量直接等于材质槽数量,而槽数量是绘制批次与着色器变体的乘数。「细节层级」是同一分区内的分辨率与细节密度分级:主角桌面的木纹要 2K,背景角落的皮凳 512 就够。
两者是正交的:分区回答「是不是同一种材质」,层级回答「这张材质配多大」。把两者写进同一份规格,才能算出「这个道具组一共几张图、多少字节」。
{
"project": "stardust-inn",
"version": 1,
"note": "写实 PBR 材质的「分区 × 细节层级」D 层规格。partition 是物理材质(wood/metal/cloth/glass/ceramic/leather/stone),tier 决定分辨率与细节密度,channelSet 决定这张材质要几张贴图。formats 与 6.1 的 st06_channels.json 同源(bc1/bc4/bc5)。全部整数。",
"formats": {
"bc1": { "block": 4, "bytes": 8 },
"bc4": { "block": 4, "bytes": 8 },
"bc5": { "block": 4, "bytes": 16 }
},
"channelSets": {
"full": { "maps": [["baseColor", "bc1"], ["normal", "bc5"], ["orm", "bc1"]] },
"standard": { "maps": [["baseColor", "bc1"], ["orm", "bc1"]] },
"simple": { "maps": [["baseColor", "bc1"]] }
},
"tiers": [
{ "id": "high", "resolution": 2048, "detailPermille": 900 },
{ "id": "mid", "resolution": 1024, "detailPermille": 600 },
{ "id": "low", "resolution": 512, "detailPermille": 300 }
],
"slots": [
{ "id": "wood_table", "partition": "wood", "tier": "high", "channelSet": "full" },
{ "id": "wood_chair", "partition": "wood", "tier": "mid", "channelSet": "standard" },
{ "id": "metal_hinge", "partition": "metal", "tier": "mid", "channelSet": "standard" },
{ "id": "brass_lamp", "partition": "metal", "tier": "high", "channelSet": "full" },
{ "id": "cloth_curtain", "partition": "cloth", "tier": "mid", "channelSet": "standard" },
{ "id": "glass_window", "partition": "glass", "tier": "high", "channelSet": "simple" },
{ "id": "ceramic_cup", "partition": "ceramic", "tier": "mid", "channelSet": "full" },
{ "id": "leather_stool", "partition": "leather", "tier": "low", "channelSet": "standard" },
{ "id": "stone_floor", "partition": "stone", "tier": "high", "channelSet": "standard" }
],
"budget": { "totalBytes": 40000000, "maxSlots": 8 }
}
细节层级:分辨率不是唯一变量
channelSet 是第三个自由度:玻璃只要一张 baseColor 就够,主角木桌却要 full 三张。规格把「几张贴图」也交给 channelSet 决定,于是同一分区分不同层级会得到完全不同的字节数。下面这段 S 层脚本读规格,逐 slot 算整链字节,再汇总、算细节密度与高细节层集中度,最后按分区合并一次看代价。
#!/usr/bin/env python3
# file: sim/st06_materials.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st06_materials.py
"""6.2 材质分区与细节层级的 S 层账。
读 config/st06_materials.json,按「分区 × 细节层级」把一张写实道具组的贴图算成整数字节:
① 逐 slot 的分辨率(由 tier 决定)、贴图数与整链字节;② 全组汇总与细节密度均值;
③ 高细节层的字节集中度;④ 按 partition 合并后的 slot 数与合并口径字节(合并代价)。
全部整数地板除,落 out/st06_materials.json 供 P 层 TS 侧对照。"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ALIGN = 4
def chain_bytes(size: int, block: int, nbytes: int) -> int:
total = 0
n = size
while True:
blocks = ((n + block - 1) // block) ** 2
total += ((blocks * nbytes) + ALIGN - 1) // ALIGN * ALIGN
if n == 1:
break
n //= 2
return total
def slot_bytes(slot, cfg):
res = next(t["resolution"] for t in cfg["tiers"] if t["id"] == slot["tier"])
cs = cfg["channelSets"][slot["channelSet"]]
total = 0
for _name, fmt in cs["maps"]:
f = cfg["formats"][fmt]
total += chain_bytes(res, f["block"], f["bytes"])
return res, len(cs["maps"]), total
def analyze(cfg):
tiers = {t["id"]: t for t in cfg["tiers"]}
slots = []
for s in cfg["slots"]:
res, nmaps, b = slot_bytes(s, cfg)
slots.append({"id": s["id"], "partition": s["partition"], "tier": s["tier"],
"resolution": res, "maps": nmaps, "bytes": b})
total = sum(s["bytes"] for s in slots)
detail = sum(tiers[s["tier"]]["detailPermille"] for s in slots)
high_bytes = sum(s["bytes"] for s in slots if s["tier"] == "high")
partitions = sorted({s["partition"] for s in slots})
# 合并口径:每个 partition 取其成员里细节最高的一张(同高则取贴图数最多的那张)
merged = []
for p in partitions:
members = [s for s in slots if s["partition"] == p]
best = max(members, key=lambda s: (tiers[s["tier"]]["detailPermille"], s["maps"]))
merged.append({"partition": p, "members": len(members), "tier": best["tier"],
"resolution": best["resolution"], "maps": best["maps"], "bytes": best["bytes"]})
merged_total = sum(m["bytes"] for m in merged)
over_bytes = total > cfg["budget"]["totalBytes"]
over_slots = len(slots) > cfg["budget"]["maxSlots"]
return {"slots": slots, "slotCount": len(slots), "partitionCount": len(partitions),
"totalBytes": total, "meanDetailPermille": detail // len(slots),
"highBytePermille": high_bytes * 1000 // total,
"merged": merged, "mergedSlotCount": len(merged), "mergedTotalBytes": merged_total,
"mergedDeltaPermille": (merged_total - total) * 1000 // total,
"overBytes": over_bytes, "overSlots": over_slots,
"violations": ([f"BYTES:OVER:{total - cfg['budget']['totalBytes']}"] if over_bytes else [])
+ ([f"SLOTS:OVER:{len(slots) - cfg['budget']['maxSlots']}"] if over_slots else [])}
def main() -> int:
cfg = json.load(open(os.path.join(ROOT, "config/st06_materials.json"), encoding="utf-8"))
rep = analyze(cfg)
canon = json.dumps({"slotCount": rep["slotCount"], "totalBytes": rep["totalBytes"],
"meanDetailPermille": rep["meanDetailPermille"],
"mergedSlotCount": rep["mergedSlotCount"], "mergedTotalBytes": rep["mergedTotalBytes"]},
separators=(",", ":"), ensure_ascii=False)
rep["digest"] = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
rep["project"] = cfg["project"]
rep["budget"] = cfg["budget"]
with open(os.path.join(ROOT, "out/st06_materials.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"slots={rep['slotCount']} partitions={rep['partitionCount']} totalBytes={rep['totalBytes']}")
for s in rep["slots"]:
print(f" {s['id']:<15} {s['partition']:<8} {s['tier']:<5} res={s['resolution']:<5} "
f"maps={s['maps']} bytes={s['bytes']}")
print(f"meanDetailPermille={rep['meanDetailPermille']} highBytePermille={rep['highBytePermille']}")
print(f"merged slots={rep['mergedSlotCount']} bytes={rep['mergedTotalBytes']} "
f"delta={rep['mergedDeltaPermille']}permille")
for v in rep["violations"]:
print(f" VIOLATION {v}")
print(f"digest={rep['digest']}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st06_materials.py(同机相对量级,绝对数值不可移植):
slots=9 partitions=7 totalBytes=38098568
wood_table wood high res=2048 maps=3 bytes=11184864
wood_chair wood mid res=1024 maps=2 bytes=1398128
metal_hinge metal mid res=1024 maps=2 bytes=1398128
brass_lamp metal high res=2048 maps=3 bytes=11184864
cloth_curtain cloth mid res=1024 maps=2 bytes=1398128
glass_window glass high res=2048 maps=1 bytes=2796216
ceramic_cup ceramic mid res=1024 maps=3 bytes=2796256
leather_stool leather low res=512 maps=2 bytes=349552
stone_floor stone high res=2048 maps=2 bytes=5592432
meanDetailPermille=700 highBytePermille=807
merged slots=7 bytes=35302312 delta=-74permille
VIOLATION SLOTS:OVER:1
digest=c074bb05a2f2e2c9
三个要点。其一,高细节层吃掉 807‰ 的字节:四个 high slot 占了全组 80% 的字节,把主角物件降到 mid 是省字节的第一杠杆。其二,分辨率翻倍不是字节翻倍:2048 的 bc1 整链是 2796216 字节,是 1024 的 699064 的 4 倍——mip 链按 1/3 递增,分辨率每翻一倍块数翻四倍。其三,按分区合并只降 74‰:9 个 slot 合并成 7 个分区只省了 2.8%,因为合并口径取的是每个分区里细节最高的一张——把 wood_chair 并进 wood_table 反而要按 2K 全链付账。合并省的是槽位与批次,不是字节;这条账恰好证明「合并材质省的是绘制调用而非显存」。
P 层:跨语言对账
下面这段是 P 层 TypeScript 内核,与 S 层同一套整数算法;测试读 Python 落盘的 JSON,不是同一份源码。
// file: core/src/st06/materials.ts
// tier: P
// verify: node --test 'core/test/st06_*.test.ts'
// 6.2 材质分区与细节层级的 P 层内核:与 sim/st06_materials.py 同一套整数算法。逐 slot 按 tier
// 定分辨率、按 channelSet 累加整链字节;合并口径取每个 partition 里细节最高的一张。字节、
// 千分比、均值全整数地板除 → 与 Python 侧逐位一致。只允许可擦除 TS 语法(禁 enum / namespace)。
export type Tier = { id: string; resolution: number; detailPermille: number };
export type Slot = { id: string; partition: string; tier: string; channelSet: string };
export type MaterialsConfig = {
formats: Record<string, { block: number; bytes: number }>;
channelSets: Record<string, { maps: [string, string][] }>;
tiers: Tier[];
slots: Slot[];
budget: { totalBytes: number; maxSlots: number };
};
const ALIGN = 4;
/** 含 1×1 的完整 mip 链字节数:块数 × 块字节,逐层 4 字节对齐。 */
export function chainBytes(size: number, block: number, bytes: number): number {
let total = 0;
for (let n = size; ; n = Math.floor(n / 2)) {
const b = Math.ceil(n / block);
total += Math.ceil((b * b * bytes) / ALIGN) * ALIGN;
if (n === 1) return total;
}
}
/** 一张 slot 的分辨率、贴图数与整链字节。 */
export function slotBytes(slot: Slot, cfg: MaterialsConfig): { resolution: number; maps: number; bytes: number } {
const res = cfg.tiers.find((t) => t.id === slot.tier)!.resolution;
const maps = cfg.channelSets[slot.channelSet].maps;
let bytes = 0;
for (const [, fmt] of maps) {
const f = cfg.formats[fmt];
bytes += chainBytes(res, f.block, f.bytes);
}
return { resolution: res, maps: maps.length, bytes };
}
/** 全组汇总 + 合并口径(每个 partition 取细节最高的一张)。全整数地板除。 */
export function analyze(cfg: MaterialsConfig): {
slotCount: number; partitionCount: number; totalBytes: number; meanDetailPermille: number;
highBytePermille: number; mergedSlotCount: number; mergedTotalBytes: number; mergedDeltaPermille: number;
violations: string[];
} {
const tierOf = (id: string): Tier => cfg.tiers.find((t) => t.id === id)!;
const rows = cfg.slots.map((s) => ({ slot: s, ...slotBytes(s, cfg) }));
const total = rows.reduce((a, r) => a + r.bytes, 0);
const detail = rows.reduce((a, r) => a + tierOf(r.slot.tier).detailPermille, 0);
const highBytes = rows.filter((r) => r.slot.tier === "high").reduce((a, r) => a + r.bytes, 0);
const partitions = [...new Set(cfg.slots.map((s) => s.partition))].sort();
let mergedTotal = 0;
for (const p of partitions) {
const members = rows.filter((r) => r.slot.partition === p);
const best = members.reduce((a, b) =>
tierOf(b.slot.tier).detailPermille > tierOf(a.slot.tier).detailPermille
|| (tierOf(b.slot.tier).detailPermille === tierOf(a.slot.tier).detailPermille && b.maps > a.maps) ? b : a);
mergedTotal += best.bytes;
}
const overBytes = total > cfg.budget.totalBytes;
const overSlots = cfg.slots.length > cfg.budget.maxSlots;
const violations: string[] = [];
if (overBytes) violations.push(`BYTES:OVER:${total - cfg.budget.totalBytes}`);
if (overSlots) violations.push(`SLOTS:OVER:${cfg.slots.length - cfg.budget.maxSlots}`);
return {
slotCount: rows.length, partitionCount: partitions.length, totalBytes: total,
meanDetailPermille: Math.floor(detail / rows.length), highBytePermille: Math.floor((highBytes * 1000) / total),
mergedSlotCount: partitions.length, mergedTotalBytes: mergedTotal,
mergedDeltaPermille: Math.floor(((mergedTotal - total) * 1000) / total), violations,
};
}
// file: core/test/st06_materials.test.ts
// tier: P
// verify: node --test 'core/test/st06_*.test.ts'
// 跨语言对照:读 sim/st06_materials.py 落盘的 out/st06_materials.json,断言 TS 侧独立复算的逐 slot
// 字节、汇总、细节密度、合并口径与违例清单逐位一致。负例:slot 超上限判 OVER、合并只降字节、
// 2× 分辨率不是 2× 字节。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chainBytes, slotBytes, analyze } from "../src/st06/materials.ts";
import type { MaterialsConfig } from "../src/st06/materials.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/st06_materials.json"), "utf8")) as MaterialsConfig;
const py = JSON.parse(readFileSync(resolve(ROOT, "out/st06_materials.json"), "utf8"));
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
test("逐 slot 分辨率、贴图数与整链字节与 Python 逐位一致", () => {
for (const s of py.slots) {
const mine = slotBytes(cfg.slots.find((x) => x.id === s.id)!, cfg);
assert.equal(mine.resolution, s.resolution, s.id);
assert.equal(mine.maps, s.maps, s.id);
assert.equal(mine.bytes, s.bytes, s.id);
}
});
test("跨语言:汇总、细节密度、合并口径、违例清单逐位一致", () => {
const mine = analyze(cfg);
for (const k of ["slotCount", "partitionCount", "totalBytes", "meanDetailPermille", "highBytePermille",
"mergedSlotCount", "mergedTotalBytes", "mergedDeltaPermille"] as const) {
assert.equal(mine[k], py[k], k);
}
assert.deepEqual(mine.violations, py.violations);
});
test("跨语言:sha256 摘要一致", () => {
const canon = JSON.stringify({
slotCount: py.slotCount, totalBytes: py.totalBytes, meanDetailPermille: py.meanDetailPermille,
mergedSlotCount: py.mergedSlotCount, mergedTotalBytes: py.mergedTotalBytes,
});
assert.equal(sha16(canon), py.digest);
});
test("负例:slot 超上限判 OVER、合并只降字节、2× 分辨率不是 2× 字节", () => {
assert.deepEqual(py.violations, ["SLOTS:OVER:1"]);
assert.ok(py.mergedDeltaPermille <= 0 && py.mergedTotalBytes < py.totalBytes);
assert.ok(chainBytes(2048, 4, 8) > 2 * chainBytes(1024, 4, 8));
});
实跑 node --test 'core/test/st06_*.test.ts'(本节 4 条,三节合计 12 条):
✔ 逐 slot 分辨率、贴图数与整链字节与 Python 逐位一致 (0.590ms)
✔ 跨语言:汇总、细节密度、合并口径、违例清单逐位一致 (0.613ms)
✔ 跨语言:sha256 摘要一致 (0.331ms)
✔ 负例:slot 超上限判 OVER、合并只降字节、2× 分辨率不是 2× 字节 (0.098ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0
账上那句 VIOLATION SLOTS:OVER:1 不是脚本的抱怨,而是规格自带的闸门:9 个槽超过 8 的上限,必须合并一个分区或把某个 slot 降级。合并材质省的是批次、降级细节省的是字节——两条杠杆的收益完全不同,这就是下一节的代价账。
阅读导航:上一节:6.1 贴图通道与打包 · 下一节:6.3 写实的性能代价 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。