6.3 写实的性能代价

前两节把贴图和材质都算成了字节,本节把「写实」相对扁平着色的代价算成一张整数账:常驻贴图数、显存字节、每像素采样数、着色器变体数、材质槽数五条可数轴,各算写实/基线的整数比值再加权成代价指数,并给出从朴素写实到逐项缓解的阶梯。真实帧时间与 GPU 占用属 X,命令行不可复现,本节只划界不伪造。

6.3 写实的性能代价

写实不是免费的风格:它要更多贴图、更多采样、更多着色器变体。站内 渲染原理专题 讲这些开销在 GPU 上如何发生,本节不复推原理——只把「写实相对扁平着色贵多少」算成一张可复核的整数账,并划清哪些量能算、哪些量必须实测。

五条可数轴

代价必须可数才能进规格。这里挑五条能直接从资产与材质规格里数出来的轴:常驻贴图数(决定采样器与带宽)、显存字节(取自 6.1 的压缩打包口径)、每像素纹理采样数(着色器实际采样的贴图条数,写实 PBR 每像素要采 baseColor + normal + ORM 三条以上)、着色器变体数(材质关键字开关的连乘)、材质槽数(取自 6.2)。五条轴全部是整数,比值与指数都用整数地板除,跨语言可逐位对账。

{
  "project": "stardust-inn",
  "version": 1,
  "note": "6.3 写实 PBR 相对扁平着色基线的代价账。五条轴全部是可数整数:常驻贴图数、显存字节(取自 6.1 压缩打包口径)、每像素纹理采样数(着色器实际采样的贴图条数)、着色器变体数(关键字开关数的连乘)、材质槽数(取自 6.2)。逐轴比值与加权指数都是整数地板除。真实帧时间与 GPU 占用需真实渲染器,命令行不可复现(X)。",
  "axes": [
    { "id": "textureMaps", "weight": 2 },
    { "id": "vramBytes", "weight": 3 },
    { "id": "samplesPerPixel", "weight": 3 },
    { "id": "shaderVariants", "weight": 2 },
    { "id": "materialSlots", "weight": 2 }
  ],
  "realistic": {
    "textureMaps": 3,
    "vramBytes": 2796256,
    "samplesPerPixel": 5,
    "shaderVariants": 16,
    "materialSlots": 9
  },
  "baseline": {
    "textureMaps": 1,
    "vramBytes": 699064,
    "samplesPerPixel": 1,
    "shaderVariants": 4,
    "materialSlots": 3
  }
}

加权代价指数与缓解阶梯

基线是扁平着色:一张 baseColor、每像素一次采样、四个变体、三个槽。写实 PBR 则要三张图、每像素五次采样、十六个变体、九个槽。逐轴比值乘以权重再取整数均值,得到一个「写实是基线的几倍」的指数;再给一条缓解阶梯,看每加一项优化指数怎么掉。

#!/usr/bin/env python3
# file: sim/st06_cost.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st06_cost.py
"""6.3 写实 PBR 的代价账(S 层)。

读 config/st06_cost.json,对五条可数轴各算「写实 / 基线 ×10」的整数比值,再按权重做整数
均值得到总代价指数(×10)。另给一次「缓解阶梯」:逐项叠加压缩、通道打包、细节降级后指数
怎么掉。全部整数地板除,落 out/st06_cost.json 供 P 层 TS 侧对照。真实帧时间与 GPU 占用需
真实渲染器,命令行不可复现(X)。"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def ratio10(real: int, base: int) -> int:
    return real * 10 // base


def index10(ratios: dict, weights: dict) -> int:
    num = sum(weights[a] * ratios[a] for a in ratios)
    return num // sum(weights.values())


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/st06_cost.json"), encoding="utf-8"))
    w = {a["id"]: a["weight"] for a in cfg["axes"]}
    real, base = cfg["realistic"], cfg["baseline"]
    rows = []
    for a in cfg["axes"]:
        i = a["id"]
        rows.append({"id": i, "weight": w[i], "realistic": real[i], "baseline": base[i],
                     "ratio10": ratio10(real[i], base[i])})
    ratios = {r["id"]: r["ratio10"] for r in rows}
    idx = index10(ratios, w)
    # 缓解阶梯:从「朴素写实」(未压缩、未打包、全高细节)出发逐项换省,看指数怎么掉。
    stages = [
        {"stage": "朴素写实", "textureMaps": 5, "vramBytes": 15379120,
         "samplesPerPixel": 5, "shaderVariants": 16, "materialSlots": 9},
        {"stage": "+ 压缩", "textureMaps": 5, "vramBytes": 4194384,
         "samplesPerPixel": 5, "shaderVariants": 16, "materialSlots": 9},
        {"stage": "+ 通道打包", "textureMaps": 3, "vramBytes": 2796256,
         "samplesPerPixel": 5, "shaderVariants": 16, "materialSlots": 9},
        {"stage": "+ 材质合并", "textureMaps": 3, "vramBytes": 2796256,
         "samplesPerPixel": 5, "shaderVariants": 16, "materialSlots": 7},
        {"stage": "+ 采样/变体裁剪", "textureMaps": 3, "vramBytes": 2796256,
         "samplesPerPixel": 3, "shaderVariants": 8, "materialSlots": 7},
    ]
    ladder = []
    for st in stages:
        cur = {a["id"]: ratio10(st[a["id"]], base[a["id"]]) for a in cfg["axes"]}
        ladder.append({"stage": st["stage"], "ratio10": cur, "index10": index10(cur, w)})
    report = {"project": cfg["project"], "axes": rows, "weights": w,
              "index10": idx, "indexRatio": f"{idx // 10}.{idx % 10}",
              "maxAxis": max(rows, key=lambda r: r["ratio10"])["id"],
              "ladder": ladder}
    canon = json.dumps({"ratios": ratios, "index10": idx}, separators=(",", ":"), ensure_ascii=False)
    report["digest"] = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
    with open(os.path.join(ROOT, "out/st06_cost.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    for r in rows:
        print(f"  {r['id']:<16} w={r['weight']} realistic={r['realistic']:<8} "
              f"baseline={r['baseline']:<8} ratio10={r['ratio10']}")
    print(f"costIndex10={idx} (~{report['indexRatio']}x baseline) maxAxis={report['maxAxis']}")
    for row in ladder:
        print(f"  ladder {row['stage']:<14} index10={row['index10']}")
    print(f"digest={report['digest']}")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st06_cost.py(同机相对量级,绝对数值不可移植):

  textureMaps      w=2 realistic=3        baseline=1        ratio10=30
  vramBytes        w=3 realistic=2796256  baseline=699064   ratio10=40
  samplesPerPixel  w=3 realistic=5        baseline=1        ratio10=50
  shaderVariants   w=2 realistic=16       baseline=4        ratio10=40
  materialSlots    w=2 realistic=9        baseline=3        ratio10=30
costIndex10=39 (~3.9x baseline) maxAxis=samplesPerPixel
  ladder 朴素写实           index10=87
  ladder + 压缩           index10=47
  ladder + 通道打包         index10=39
  ladder + 材质合并         index10=38
  ladder + 采样/变体裁剪      index10=29
digest=c31834c9f0978e5b

三处结论。其一,写实整体约 3.9× 基线,最贵的一条轴是每像素采样数(ratio10=50,5 倍)——写实 PBR 的瓶颈通常不在贴图数量而在每像素要采几次。其二,缓解阶梯的前两档收益最大:朴素写实(未压缩、未打包)是 87,加压缩掉到 47,加通道打包掉到 39;后两档(材质合并 38、采样/变体裁剪 29)单看数字小,但它们省的是绘制批次与变体数,属于另一类瓶颈。其三,显存轴不是简单通道比:2796256 / 699064 = 4.0,正好是分辨率翻倍带来的四倍,与 6.2 的 mip 链结论一致。

阶段贴图数显存字节采样/像素变体槽指数
朴素写实515379120516987
+ 压缩54194384516947
+ 通道打包32796256516939
+ 材质合并32796256516738
+ 采样/变体裁剪3279625638729

P 层:跨语言对账

下面这段是 P 层 TypeScript 内核,与 S 层同一套整数算法;测试读 Python 落盘的 JSON,不是同一份源码。

// file: core/src/st06/cost.ts
// tier: P
// verify: node --test 'core/test/st06_*.test.ts'
// 6.3 写实代价账的 P 层内核:与 sim/st06_cost.py 同一套整数算法——逐轴比值
// real*10//base,再按权重做整数均值得到代价指数(×10)。全整数地板除 → 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Axis = { id: string; weight: number };

/** 逐轴比值 ×10:整数地板除。 */
export function ratio10(real: number, base: number): number {
  return Math.floor((real * 10) / base);
}

/** 加权整数均值:sum(w × ratio) / sum(w),地板除。 */
export function index10(ratios: Record<string, number>, weights: Record<string, number>): number {
  let num = 0;
  let den = 0;
  for (const id of Object.keys(ratios)) {
    num += weights[id] * ratios[id];
    den += weights[id];
  }
  return Math.floor(num / den);
}

/** 全表:逐轴比值 + 加权指数。 */
export function ledger(
  axes: Axis[],
  real: Record<string, number>,
  base: Record<string, number>,
): { rows: { id: string; weight: number; realistic: number; baseline: number; ratio10: number }[];
  ratios: Record<string, number>; index10: number } {
  const rows = axes.map((a) => ({
    id: a.id, weight: a.weight, realistic: real[a.id], baseline: base[a.id],
    ratio10: ratio10(real[a.id], base[a.id]),
  }));
  const ratios: Record<string, number> = {};
  const weights: Record<string, number> = {};
  for (const r of rows) { ratios[r.id] = r.ratio10; weights[r.id] = r.weight; }
  return { rows, ratios, index10: index10(ratios, weights) };
}
// file: core/test/st06_cost.test.ts
// tier: P
// verify: node --test 'core/test/st06_*.test.ts'
// 跨语言对照:读 sim/st06_cost.py 落盘的 out/st06_cost.json,断言 TS 侧独立复算的逐轴比值、
// 加权指数与 sha256 摘要与之逐位一致。负例:比值必须是整数(不是浮点近似)、
// 采样数轴是最大代价轴、缓解阶梯单调不升。
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 { ratio10, index10, ledger } from "../src/st06/cost.ts";
import type { Axis } from "../src/st06/cost.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/st06_cost.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/st06_cost.json"), "utf8"));
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);

test("跨语言:逐轴比值与加权指数逐位一致", () => {
  const mine = ledger(cfg.axes as Axis[], cfg.realistic, cfg.baseline);
  assert.equal(mine.index10, py.index10);
  for (const r of py.axes) {
    const m = mine.rows.find((x) => x.id === r.id)!;
    assert.equal(m.ratio10, r.ratio10, r.id);
    assert.equal(m.realistic, r.realistic, r.id);
  }
});

test("跨语言:缓解阶梯每一级的指数一致且单调不升", () => {
  const w: Record<string, number> = {};
  for (const a of cfg.axes as Axis[]) w[a.id] = a.weight;
  let prev = Number.MAX_SAFE_INTEGER;
  for (const st of py.ladder) {
    const idx = index10(st.ratio10, w);
    assert.equal(idx, st.index10, st.stage);
    assert.ok(idx <= prev, `${st.stage} 不应比上一级更贵`);
    prev = idx;
  }
});

test("跨语言:sha256 摘要一致", () => {
  const ratios: Record<string, number> = {};
  for (const r of py.axes) ratios[r.id] = r.ratio10;
  const canon = JSON.stringify({ ratios, index10: py.index10 });
  assert.equal(sha16(canon), py.digest);
});

test("负例:比值是整数地板除、采样轴最大、字节轴不是简单通道比", () => {
  assert.equal(ratio10(5, 1), 50);
  assert.equal(ratio10(2796256, 699064), 40);
  assert.equal(ratio10(2796256, 699064) * 699064, 27962560); // 恰好整除
  assert.equal(py.maxAxis, "samplesPerPixel");
  assert.ok(py.index10 > 30 && py.index10 < 50);
});

实跑 node --test 'core/test/st06_*.test.ts'(本节 4 条,三节合计 12 条):

✔ 跨语言:逐轴比值与加权指数逐位一致 (0.601ms)
✔ 跨语言:缓解阶梯每一级的指数一致且单调不升 (0.130ms)
✔ 跨语言:sha256 摘要一致 (0.331ms)
✔ 负例:比值是整数地板除、采样轴最大、字节轴不是简单通道比 (0.066ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

X:真实帧时间不可复现

上面这张账是资产侧的代价,它可数、可对账、可进规格。但美术最关心的「这一版会不会掉帧」是运行侧的量:真实帧时间、GPU 占用、带宽饱和点——这些依赖具体 GPU、驱动版本、场景复杂度、分辨率与画质档,命令行不可复现,因此本节标 X,只划界不伪造。要判断写实是否超预算,正确做法是把这张整数账当作先验筛选(采样数与变体数超标就先怀疑),再在真实渲染器里实测确认。账能替你排除显然超标的方案,但替代不了实测。

阅读导航:上一节:6.2 材质分区与细节层级 · 下一节:7.1 笔刷与色域参数规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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