6.2 参数对齐与打包

参数对齐不是玄学:一个常量寄存器是 16 字节 = float4,参数必须整块落在某个寄存器内、不许跨边界,放不下就顺延、余量作废。本节用《星屑旅店》的 10 个参数把「按逻辑顺序声明」与「按寄存器边界重排」各算一遍,给出可复现的整数账——naive 占 5 个寄存器、packed 占 4 个,S 层与 P 层逐位一致。

材质参数不是「声明了就行」。同样 10 个参数,声明顺序不同,占用的常量寄存器数就可能不同——这不是风格问题,而是硬件层面的对齐约束。本节把 6.1 的参数表往前推一步:给定每个参数的分量数,算清楚它到底要几个寄存器、利用率是多少。全篇是整数账,命令行可复现。

6.2 参数对齐与打包

6.2.1 一个寄存器 = float4,参数不许跨边界

在 GPU 里,常量寄存器的最小单位是 16 字节 = 一个 float4,也就是 4 个分量。一个 float3 参数占 3 个分量,剩下的 1 个分量不能给别人用——除非下一个参数正好放得下。于是声明顺序是 float3 / float2 / float3 时,第二个 float3 因为 4 个分量放不下而顺延到下一个寄存器,白白空出 2 个分量。

参数不能跨寄存器边界,这是硬约束:一个参数要么整块落在某个寄存器内,要么整体顺延。所以「对齐」不是把参数排整齐,而是让参数之间的空隙被填满。

6.2.2 整数账:寄存器数 / 已用 / 作废 / 千分比

把它写成整数账:设寄存器容量 cpr = 4 个分量,按声明顺序贪心首适配——能塞进当前寄存器就塞,塞不下就顺延到下一个(余量作废)。于是有四个整数:寄存器数 registers、已用分量 used、作废分量 wasted = registers × cpr − used、利用率千分比 permille = used × 1000 // (registers × cpr)。全是整数运算,没有浮点,两侧逐位一致。

6.2.3 打包配置(D 层)

下面这个块是 D 层:10 个参数的分量数(来自 6.1 的参数类型),以及两个声明顺序——naive(按材质逻辑顺序,两个 3 分量参数夹着一个 2 分量参数)与 packed(按寄存器边界重排,3 分量后紧跟 1 分量补满):

{
  "project": "stardust-inn",
  "ruleSet": "param-packing",
  "specRef": "config/visual_spec.json",
  "note": "参数对齐与打包:一个常量寄存器是 16 字节 = 4 个分量(float4)。每个参数占若干分量,必须整块落在某个寄存器内、不许跨寄存器边界;放不下就顺延到下一个寄存器(余量作废)。本表把「按逻辑顺序声明」与「按寄存器边界重排」两种顺序各算一遍,产出整数账:寄存器数 / 已用分量 / 作废分量 / 利用率千分比。全部整数,两侧逐位一致。",
  "registerBytes": 16,
  "componentsPerRegister": 4,
  "params": [
    { "name": "_BaseColor", "components": 3 },
    { "name": "_EmissionColor", "components": 3 },
    { "name": "_TilingScale", "components": 2 },
    { "name": "_Roughness", "components": 1 },
    { "name": "_Metallic", "components": 1 },
    { "name": "_EmissionStrength", "components": 1 },
    { "name": "_FlickerSpeed", "components": 1 },
    { "name": "_OutlineWidth", "components": 1 },
    { "name": "_AlphaCutoff", "components": 1 },
    { "name": "_SoftEdge", "components": 1 }
  ],
  "variants": [
    {
      "id": "naive",
      "label": "反例:按材质逻辑顺序声明,两个 3 分量参数夹着 2 分量参数",
      "order": ["_BaseColor", "_TilingScale", "_EmissionColor", "_Roughness", "_Metallic", "_EmissionStrength", "_FlickerSpeed", "_OutlineWidth", "_AlphaCutoff", "_SoftEdge"]
    },
    {
      "id": "packed",
      "label": "正例:按寄存器边界重排,3 分量参数后紧跟 1 分量参数补满",
      "order": ["_EmissionColor", "_Roughness", "_BaseColor", "_Metallic", "_TilingScale", "_EmissionStrength", "_FlickerSpeed", "_OutlineWidth", "_AlphaCutoff", "_SoftEdge"]
    }
  ]
}

_BaseColor 是 3 分量的 color、_TilingScale 是 2 分量的 float2——分量数不是随便定的,它由参数类型决定(见 6.1 材质参数表与默认值 )。同一个参数集,换个声明顺序就少占一个寄存器。

6.2.4 S 层:两个变体的整数账

S 层脚本对两个变体各算一遍,落 out/ta06_packing.json:

#!/usr/bin/env python3
# file: sim/ta06_packing.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta06_packing.py
"""6.2 参数对齐与打包的整数账(S 层)。一个常量寄存器 = 16 字节 = 4 个分量(float4),规则三条:
参数整块落在某个寄存器内、不许跨寄存器边界;放不下就顺延到下一个寄存器(余量作废);声明顺序
不同寄存器数就不同。对 naive(逻辑顺序)与 packed(重排)两个变体各算一遍,落 out/ta06_packing.json,
断言任何参数都不跨寄存器边界(boundaryCrossings == 0)。
"""
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/ta06_packing.json")
OUT = os.path.join(ROOT, "out")


def pack(order, comp, cpr):
    """贪心首适配:按 order 顺序放参数,不跨寄存器边界,放不下就顺延。"""
    cursor = 0
    placed = []
    crossings = 0
    for name in order:
        c = comp[name]
        if c > cpr:
            crossings += 1  # 单个参数比一个寄存器还大:无法打包
        if cursor % cpr + c > cpr:
            cursor = ((cursor + cpr - 1) // cpr) * cpr  # 顺延到下一个寄存器
        placed.append({"name": name, "register": cursor // cpr, "offset": cursor % cpr, "components": c})
        cursor += c
    registers = (cursor + cpr - 1) // cpr
    used = sum(p["components"] for p in placed)
    capacity = registers * cpr
    return {"registers": registers, "used": used, "capacity": capacity,
            "wasted": capacity - used, "permille": used * 1000 // capacity,
            "boundaryCrossings": crossings, "placed": placed}


def main():
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    cpr = cfg["componentsPerRegister"]
    comp = {p["name"]: p["components"] for p in cfg["params"]}
    total_comp = sum(comp.values())
    variants = []
    for v in cfg["variants"]:
        assert set(v["order"]) == set(comp), f"{v['id']} 顺序与参数集不符"
        r = pack(v["order"], comp, cpr)
        r.update({"id": v["id"], "label": v["label"]})
        variants.append(r)

    lines = [f"{v['id']}|{v['registers']}|{v['used']}|{v['wasted']}|{v['permille']}" for v in variants]
    digest = hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]

    naive = next(v for v in variants if v["id"] == "naive")
    packed = next(v for v in variants if v["id"] == "packed")
    summary = {
        "project": cfg["project"], "ruleSet": cfg["ruleSet"],
        "componentsPerRegister": cpr, "registerBytes": cfg["registerBytes"],
        "paramCount": len(cfg["params"]), "totalComponents": total_comp,
        "variants": variants, "digest": digest,
        "savedRegisters": naive["registers"] - packed["registers"],
        "permilleGain": packed["permille"] - naive["permille"],
        "allBoundarySafe": all(v["boundaryCrossings"] == 0 for v in variants),
    }
    with open(os.path.join(OUT, "ta06_packing.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"componentsPerRegister={cpr} params={len(cfg['params'])} totalComponents={total_comp}")
    for v in variants:
        print(f"  [{v['id']}] registers={v['registers']} used={v['used']} wasted={v['wasted']} "
              f"permille={v['permille']} boundaryCrossings={v['boundaryCrossings']}")
    print("  [packed] 参数落位:")
    for p in packed["placed"]:
        print(f"      {p['name']:<18} reg{p['register']} +{p['offset']} ({p['components']} comp)")
    print(f"savedRegisters={summary['savedRegisters']} permilleGain={summary['permilleGain']} "
          f"allBoundarySafe={summary['allBoundarySafe']}")
    print(f"digest={digest}")
    print("wrote out/ta06_packing.json")
    return 0 if summary["allBoundarySafe"] else 1


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

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

componentsPerRegister=4 params=10 totalComponents=15
  [naive] registers=5 used=15 wasted=5 permille=750 boundaryCrossings=0
  [packed] registers=4 used=15 wasted=1 permille=937 boundaryCrossings=0
  [packed] 参数落位:
      _EmissionColor     reg0 +0 (3 comp)
      _Roughness         reg0 +3 (1 comp)
      _BaseColor         reg1 +0 (3 comp)
      _Metallic          reg1 +3 (1 comp)
      _TilingScale       reg2 +0 (2 comp)
      _EmissionStrength  reg2 +2 (1 comp)
      _FlickerSpeed      reg2 +3 (1 comp)
      _OutlineWidth      reg3 +0 (1 comp)
      _AlphaCutoff       reg3 +1 (1 comp)
      _SoftEdge          reg3 +2 (1 comp)
savedRegisters=1 permilleGain=187 allBoundarySafe=True
digest=f2c86825df9d692d
wrote out/ta06_packing.json

naive 占 5 个寄存器、利用率 750‰;packed 占 4 个、937‰——只是把 3 分量参数和 1 分量参数配成对,就省下一个寄存器。落位读法:reg0 装 _EmissionColor(3) + _Roughness(1),reg1 装 _BaseColor(3) + _Metallic(1),reg2 装 _TilingScale(2) + _EmissionStrength(1) + _FlickerSpeed(1),reg3 只剩 3 个分量。boundaryCrossings=0:没有任何参数跨边界。

6.2.5 P 层内核

同一套贪心首适配,TypeScript 侧独立实现一遍,只读同一份配置:

// file: core/src/ta06/packing.ts
// tier: P
// verify: node --test 'core/test/ta06_*.test.ts'
// 6.2 参数对齐与打包的 TS 内核。与 sim/ta06_packing.py 同一套规则:一个寄存器 4 个分量,
// 参数整块落在寄存器内、不跨边界,放不下就顺延到下一个寄存器(余量作废)。全部整数,
// 除法一律整数地板除,两侧逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Placement = { name: string; register: number; offset: number; components: number };
export type PackResult = {
  registers: number; used: number; capacity: number; wasted: number;
  permille: number; boundaryCrossings: number; placed: Placement[];
};

export function pack(order: string[], comp: Record<string, number>, cpr: number): PackResult {
  let cursor = 0;
  const placed: Placement[] = [];
  let crossings = 0;
  for (const name of order) {
    const c = comp[name];
    if (c > cpr) crossings += 1;
    if (cursor % cpr + c > cpr) cursor = Math.floor((cursor + cpr - 1) / cpr) * cpr;
    placed.push({ name, register: Math.floor(cursor / cpr), offset: cursor % cpr, components: c });
    cursor += c;
  }
  const registers = Math.floor((cursor + cpr - 1) / cpr);
  const used = placed.reduce((a, p) => a + p.components, 0);
  const capacity = registers * cpr;
  return {
    registers, used, capacity, wasted: capacity - used,
    permille: Math.floor((used * 1000) / capacity),
    boundaryCrossings: crossings, placed,
  };
}

/** 断言:没有任何参数跨寄存器边界(offset + components <= cpr)。 */
export function boundarySafe(r: PackResult, cpr: number): boolean {
  return r.placed.every((p) => p.offset + p.components <= cpr);
}

6.2.6 P 层测试:跨语言对照与负例

P 层测试读 Python 落盘的 out/ta06_packing.json,逐字段对照两个变体的排布,并断言「naive 必须比 packed 多占寄存器」这个负例:

// file: core/test/ta06_packing.test.ts
// tier: P
// verify: node --test 'core/test/ta06_*.test.ts'
// 跨语言对照:读 sim/ta06_packing.py 落盘的 out/ta06_packing.json,断言 TS 侧的寄存器排布账
// 逐位一致。负例:naive 顺序必须比 packed 多占寄存器;任何参数都不得跨寄存器边界。
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 { pack, boundarySafe } from "../src/ta06/packing.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta06_packing.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta06_packing.json"), "utf8"));
const comp: Record<string, number> = Object.fromEntries(cfg.params.map((p: any) => [p.name, p.components]));
const cpr = cfg.componentsPerRegister;
const digest = (ls: string[]): string => createHash("sha256").update([...ls].sort().join("\n")).digest("hex").slice(0, 16);

test("跨语言:TS 寄存器排布账与 Python 产物逐位一致", () => {
  const lines: string[] = [];
  cfg.variants.forEach((v: any, i: number) => {
    const mine = pack(v.order, comp, cpr);
    const theirs = py.variants[i];
    for (const k of ["registers", "used", "capacity", "wasted", "permille", "boundaryCrossings"]) {
      assert.equal(mine[k], theirs[k], `${v.id} ${k}`);
    }
    assert.deepEqual(mine.placed, theirs.placed, `${v.id} placed`);
    lines.push(`${v.id}|${mine.registers}|${mine.used}|${mine.wasted}|${mine.permille}`);
  });
  assert.equal(digest(lines), py.digest);
  assert.equal(py.digest, "f2c86825df9d692d");
  console.log(`packing digest = ${digest(lines)}`);
});

test("负例:naive 逻辑顺序必须比 packed 重排多占寄存器", () => {
  const naive = py.variants.find((v: any) => v.id === "naive");
  const packed = py.variants.find((v: any) => v.id === "packed");
  assert.equal(naive.registers, 5);
  assert.equal(packed.registers, 4);
  assert.ok(naive.registers > packed.registers);
  assert.equal(py.savedRegisters, 1);
  assert.equal(naive.permille, 750);
  assert.equal(packed.permille, 937);
  assert.ok(packed.permille > naive.permille);
});

test("不变量:任何参数都不跨寄存器边界,分量总数守恒", () => {
  assert.equal(py.allBoundarySafe, true);
  for (const v of cfg.variants) {
    const r = pack(v.order, comp, cpr);
    assert.ok(boundarySafe(r, cpr), `${v.id} 有参数跨边界`);
    assert.equal(r.boundaryCrossings, 0);
    assert.equal(r.used, py.totalComponents);
  }
});

test("正例:一个 3 分量后紧跟 1 分量能填满同一寄存器;越界必须顺延", () => {
  const r = pack(["_EmissionColor", "_Roughness", "_TilingScale"], { _EmissionColor: 3, _Roughness: 1, _TilingScale: 2 }, 4);
  assert.equal(r.registers, 2);
  assert.deepEqual(r.placed[1], { name: "_Roughness", register: 0, offset: 3, components: 1 });
  assert.deepEqual(r.placed[2], { name: "_TilingScale", register: 1, offset: 0, components: 2 });
});

实跑 node --test 'core/test/ta06_*.test.ts'(ta06_* 三节共 13 条,此处只列本节相关的 4 条与汇总):

packing digest = f2c86825df9d692d
✔ 跨语言:TS 寄存器排布账与 Python 产物逐位一致
✔ 负例:naive 逻辑顺序必须比 packed 重排多占寄存器
✔ 不变量:任何参数都不跨寄存器边界,分量总数守恒
✔ 正例:一个 3 分量后紧跟 1 分量能填满同一寄存器;越界必须顺延
ℹ tests 13
ℹ pass 13
ℹ fail 0

packing digest = f2c86825df9d692d,与 Python 侧逐位一致。边界:真实 GPU 的常量缓冲区规则——编译器是否自动重排、驱动是否补齐、不同图形 API 的寄存器粒度差异——需真实环境,命令行不可复现,标 X;本节算的是可复现的整数账:给定「一个寄存器 4 个分量、参数不许跨边界」这个模型,寄存器数与利用率就是确定的。另外,本节只算布局账,不算「省一个寄存器能提升多少帧率」——那是性能预算专题的事。

阅读导航:上一节:6.1 材质参数表与默认值 · 下一节:6.3 划界:编译与渲染原理外链 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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