6.2 材质分区与细节层级

上一节把五张贴图压成三张,本节解决另一半问题:一个道具组到底该切几张材质。把「物理材质分区 × 细节层级」写成 D 层规格,再按每 slot 的分辨率与贴图集算出整链字节,逐位对账:细节密度均值、高细节层字节集中度、按分区合并后的槽位与字节降幅,并让超预算的槽位在账上直接报 OVER。

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 写实的性能代价 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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