6.1 材质参数表与默认值

材质参数表不是美术的内部草稿,而是美术与程序之间的契约:每个参数都要写明类型、取值范围、单位、默认值与是否暴露给美术。本节用《星屑旅店》三套材质做一份可跑的表校验,并让「默认值被改动」这件事在构建期就被摘要检测到,S 层与 P 层结果逐位一致。

材质是美术在引擎里最常碰、也最容易碰坏的东西。画师调一个 _Roughness,程序把默认值改回去,谁都不知道「哪个值才是对的」——这类扯皮几乎都源于同一件事:材质参数没有一份契约。本章只做材质侧的规范,不做渲染原理:着色器怎么编译、一次编译出多少变体、渲染管线怎么组织 pass,都是 graphics 专题的地盘(着色器编译管线 、渲染原理专题 ),本卷不重推。本节只做一件事——把「一个材质有哪些参数、默认值是什么」写成一份能被机器检查的表。

6.1 材质参数表与默认值

6.1.1 参数表是契约,不是文档

只写在 wiki 上的参数表,迟早会和引擎里的实际值对不上。要让它有用,每条参数必须是结构化的八件套:

字段含义缺了会怎样
name参数名(如 _Roughness)程序对不上号
typefloat / float2 / color / bool …打包与对齐无从谈起(6.2)
min / max取值范围美术能填进一个让画面崩掉的值
default默认值谁都不知道初始状态该是什么
unit单位(ratio / tiles / hz …)0.5 是「一半」还是「半秒」说不清
expose是否暴露给美术不该动的值被动了
reason默认值为什么是这个值表退化成「一堆数字」

最后一行最容易被省掉。_Roughness 默认 0.8 不是随便填的——低端机上没有反射探针,粗糙度调低在视觉上没有回报,所以默认就该偏高。把理由写进表里,默认值才有「不许乱改」的分量。

6.1.2 默认值变了,要能被检测到

真正的风险是:某天有人顺手把 _Roughness 从 0.8 改成 0.6 提交进版本库,没人发现,直到美术发现「地砖怎么变亮了」。所以参数表要带一个摘要:把全部 (material, param, default) 三元组排好序、求一个哈希,作为基线存进配置;校验器每次跑都重算摘要与基线比对——只要有一个默认值动了,摘要就变,构建期就能报出来。

6.1.3 参数表配置(D 层)

下面这个块是 D 层,只定义输入与判据:三套材质(地砖 / 灯具 / UI 图标)的参数表,以及一条 baselineDigest 基线:

{
  "project": "stardust-inn",
  "ruleSet": "material-params",
  "specRef": "config/visual_spec.json",
  "note": "材质参数表:每条都是美术与程序之间的契约。字段 name/type/min/max/default/unit/expose/reason 缺一不可;数值型 default 必须落在 [min,max];reason 解释默认值为什么是这个值。baselineDigest 是全部 (material,param,default) 的摘要,默认值一动摘要就变。数值写成安全字面量,两侧归一后逐位一致。",
  "baselineDigest": "888ebde73d2808cc",
  "allowedTypes": ["float", "float2", "float3", "float4", "color", "bool"],
  "componentCount": { "float": 1, "float2": 2, "float3": 3, "float4": 4 },
  "materials": [
    { "id": "M_Floor_Tile", "shader": "Inn/LitOpaque", "params": [
      { "name": "_BaseColor", "type": "color", "min": null, "max": null, "default": [214, 201, 176], "unit": "srgb8", "expose": true, "reason": "木地砖暖灰基准色,取调色板 surface 的明度档" },
      { "name": "_Roughness", "type": "float", "min": 0, "max": 1, "default": [0.8], "unit": "ratio", "expose": true, "reason": "哑光瓷砖;低端机无反射探针,低粗糙度看不出差异,故默认偏高" },
      { "name": "_Metallic", "type": "float", "min": 0, "max": 1, "default": [0], "unit": "ratio", "expose": false, "reason": "非金属,程序按材质类别锁定,不暴露给美术以免误改" },
      { "name": "_TilingScale", "type": "float2", "min": 1, "max": 16, "default": [4, 4], "unit": "tiles", "expose": true, "reason": "64px tile 铺 256px 地块,整数 4 格,避免非整数平铺出接缝" }
    ]},
    { "id": "M_Furn_Lamp", "shader": "Inn/Emissive", "params": [
      { "name": "_BaseColor", "type": "color", "min": null, "max": null, "default": [245, 158, 11], "unit": "srgb8", "expose": true, "reason": "琥珀灯罩,取调色板 accent token,与 UI 强调色同源" },
      { "name": "_EmissionStrength", "type": "float", "min": 0, "max": 8, "default": [3], "unit": "x", "expose": true, "reason": "暖光灯具默认 3 倍,是夜间场景不溢出的经验中位" },
      { "name": "_FlickerSpeed", "type": "float", "min": 0, "max": 4, "default": [0.5], "unit": "hz", "expose": true, "reason": "烛火微闪;0 即恒定光,默认取低速率避免频闪致眩晕" }
    ]},
    { "id": "M_UI_Icon", "shader": "Inn/UISprite", "params": [
      { "name": "_Tint", "type": "color", "min": null, "max": null, "default": [236, 239, 241], "unit": "srgb8", "expose": true, "reason": "默认取调色板 ink token,图标不染色时即原色,便于图集复用" },
      { "name": "_AlphaCutoff", "type": "float", "min": 0, "max": 1, "default": [0.5], "unit": "ratio", "expose": true, "reason": "图集图标默认硬切;0.5 是二值化中点,两侧余量对称" },
      { "name": "_SoftEdge", "type": "bool", "min": null, "max": null, "default": [0], "unit": "bool", "expose": true, "reason": "图标默认硬边;关闭软边可省一个分支,需要抗锯齿时才打开" }
    ]}
  ]
}

注意 _Metallic 的 expose 是 false:金属度由程序按材质类别锁定,不暴露给美术,免得有人在灯具上把它拉到 1 做出一个塑料金块。

6.1.4 S 层:校验与默认值摘要

下面这段是 S 层脚本,校验八件套、算默认值摘要、并与基线对照:

#!/usr/bin/env python3
# file: sim/ta06_params.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta06_params.py
"""6.1 材质参数表校验与默认值摘要(S 层)。参数表是契约:每个参数要有
name/type/min/max/default/unit/expose/reason 八件套,数值型 default 落在 [min,max],reason 解释
默认值为什么是这个值;校验全表、对 (material,param,default) 求 sha256 摘要与 baselineDigest 对照,
再扰动一个默认值证明可检测。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REQ = ("name", "type", "min", "max", "default", "unit", "expose", "reason")


def digest_of(materials):
    lines = sorted(f"{m['id']}|{p['name']}|{','.join('%g' % v for v in p['default'])}"
                   for m in materials for p in m["params"])
    return hashlib.sha256("\n".join(lines).encode()).hexdigest()[:16]


def check(mid, p, cfg):
    e = [(mid, p.get("name", "?"), "E_MISSING_FIELD", f) for f in REQ if f not in p]
    if e:
        return e
    if p["type"] not in cfg["allowedTypes"]:
        return [(mid, p["name"], "E_BAD_TYPE", p["type"])]
    if not isinstance(p["expose"], bool):
        e.append((mid, p["name"], "E_EXPOSE_TYPE", str(p["expose"])))
    if not p["unit"]:
        e.append((mid, p["name"], "E_NO_UNIT", ""))
    if not isinstance(p["reason"], str) or len(p["reason"]) < 6:
        e.append((mid, p["name"], "E_NO_REASON", str(p["reason"])))
    t, d = p["type"], p["default"]
    if t in cfg["componentCount"]:
        want = cfg["componentCount"][t]
        if len(d) != want:
            e.append((mid, p["name"], "E_COMPONENTS", f"{len(d)}!={want}"))
        if p["min"] is None or p["max"] is None or p["min"] > p["max"]:
            e.append((mid, p["name"], "E_RANGE", f"{p['min']}..{p['max']}"))
        else:
            e += [(mid, p["name"], "E_DEFAULT_RANGE", f"[{i}]={'%g' % v}")
                  for i, v in enumerate(d) if not p["min"] <= v <= p["max"]]
    elif t == "color" and (len(d) != 3 or any(not 0 <= v <= 255 for v in d)):
        e.append((mid, p["name"], "E_COLOR", ",".join(map(str, d))))
    elif t == "bool" and d not in ([0], [1]):
        e.append((mid, p["name"], "E_BOOL", str(d)))
    return e


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/ta06_material.json"), encoding="utf-8"))
    os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
    violations = []
    for m in cfg["materials"]:
        seen = set()
        for p in m["params"]:
            if p.get("name") in seen:
                violations.append((m["id"], p["name"], "E_DUP_NAME", p["name"]))
            seen.add(p.get("name"))
            violations.extend(check(m["id"], p, cfg))
    violations.sort(key=lambda v: v[:3])
    digest = digest_of(cfg["materials"])
    probe, what = json.loads(json.dumps(cfg["materials"])), ""
    for m in probe:
        for p in m["params"]:
            if p["type"] in cfg["componentCount"]:
                p["default"] = [min(p["default"][0] + 0.01, p["max"])] + p["default"][1:]
                what = f"{m['id']}.{p['name']}"
                break
        if what:
            break
    pert = digest_of(probe)
    params = sum(len(m["params"]) for m in cfg["materials"])
    exposed = sum(1 for m in cfg["materials"] for p in m["params"] if p["expose"] is True)
    out = {"project": cfg["project"], "ruleSet": cfg["ruleSet"], "materialCount": len(cfg["materials"]),
           "paramCount": params, "exposedCount": exposed, "hiddenCount": params - exposed, "violationCount": len(violations),
           "violations": [{"material": a, "param": b, "code": c, "token": d} for a, b, c, d in violations],
           "defaultsDigest": digest, "baselineDigest": cfg["baselineDigest"], "defaultsChanged": digest != cfg["baselineDigest"],
           "perturbedWhat": what, "perturbedDigest": pert, "perturbationDetected": pert != digest}
    with open(os.path.join(ROOT, "out/ta06_params.json"), "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"materials={out['materialCount']} params={params} exposed={exposed} hidden={params - exposed} violations={len(violations)}")
    print(f"defaultsDigest={digest} baseline={cfg['baselineDigest']} defaultsChanged={out['defaultsChanged']}")
    print(f"perturb({what}) -> {pert} detected={out['perturbationDetected']}")
    print("wrote out/ta06_params.json")
    return 1 if violations or out["defaultsChanged"] else 0


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

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

materials=3 params=10 exposed=9 hidden=1 violations=0
defaultsDigest=888ebde73d2808cc baseline=888ebde73d2808cc defaultsChanged=False
perturb(M_Floor_Tile._Roughness) -> 8c11e50b60c0b3db detected=True
wrote out/ta06_params.json

全表 10 个参数、9 个暴露给美术、零违例,defaultsDigest 与基线一致。第三行是变更检测的演示:脚本在内存里把 _Roughness 从 0.8 扰动到 0.81,摘要立刻从 888ebde7… 变成 8c11e50b…,detected=True——这就是「默认值变了能被检测到」的可跑证据。

6.1.5 P 层内核

同一套规则,TypeScript 侧独立实现一遍,只读同一份配置:

// file: core/src/ta06/material.ts
// tier: P
// verify: node --test 'core/test/ta06_*.test.ts'
// 6.1 材质参数表校验与默认值摘要的 TS 内核。与 sim/ta06_params.py 同一套规则:八件套齐全、
// 数值型 default 落在 [min,max]、reason 解释默认值;对 (material,param,default) 求 sha256 摘要。
// 数值归一到 String(),与 Python "%g" 在本章取值域内逐位一致。只允许可擦除 TS 语法。
import { createHash } from "node:crypto";

export type ParamType = "float" | "float2" | "float3" | "float4" | "color" | "bool";
export type MaterialParam = { name: string; type: ParamType; min: number | null; max: number | null; default: number[]; unit: string; expose: boolean; reason: string };
export type Material = { id: string; shader: string; params: MaterialParam[] };
export type ParamConfig = { project: string; ruleSet: string; allowedTypes: ParamType[]; componentCount: Record<string, number>; baselineDigest: string; materials: Material[] };
export type Violation = { material: string; param: string; code: string; token: string };
const REQ = ["name", "type", "min", "max", "default", "unit", "expose", "reason"];
const num = (v: number): string => String(v);

export function digestOf(materials: Material[]): string {
  const lines: string[] = [];
  for (const m of materials) for (const p of m.params) lines.push(`${m.id}|${p.name}|${p.default.map(num).join(",")}`);
  lines.sort();
  return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}

function checkParam(mid: string, p: any, cfg: ParamConfig): Violation[] {
  const e: Violation[] = [];
  const V = (code: string, token: string) => e.push({ material: mid, param: p.name, code, token });
  for (const f of REQ) if (!(f in p)) V("E_MISSING_FIELD", f);
  if (e.length) return e;
  if (!cfg.allowedTypes.includes(p.type)) return [{ material: mid, param: p.name, code: "E_BAD_TYPE", token: String(p.type) }];
  if (typeof p.expose !== "boolean") V("E_EXPOSE_TYPE", String(p.expose));
  if (!p.unit) V("E_NO_UNIT", "");
  if (typeof p.reason !== "string" || p.reason.length < 6) V("E_NO_REASON", String(p.reason));
  const d = p.default;
  if (p.type in cfg.componentCount) {
    const want = cfg.componentCount[p.type];
    if (d.length !== want) V("E_COMPONENTS", `${d.length}!=${want}`);
    if (p.min === null || p.max === null || p.min > p.max) V("E_RANGE", `${p.min}..${p.max}`);
    else d.forEach((v: number, i: number) => { if (!(v >= p.min && v <= p.max)) V("E_DEFAULT_RANGE", `[${i}]=${num(v)}`); });
  } else if (p.type === "color") {
    if (d.length !== 3 || d.some((v: number) => !(v >= 0 && v <= 255))) V("E_COLOR", d.join(","));
  } else if (p.type === "bool" && !(d.length === 1 && (d[0] === 0 || d[0] === 1))) V("E_BOOL", d.join(","));
  return e;
}

export function validate(cfg: ParamConfig): { violations: Violation[]; paramCount: number; exposedCount: number } {
  const violations: Violation[] = [];
  let paramCount = 0;
  let exposedCount = 0;
  for (const m of cfg.materials) {
    const seen = new Set<string>();
    for (const p of m.params) {
      paramCount += 1;
      if (p.expose === true) exposedCount += 1;
      if (seen.has(p.name)) violations.push({ material: m.id, param: p.name, code: "E_DUP_NAME", token: p.name });
      seen.add(p.name);
      violations.push(...checkParam(m.id, p, cfg));
    }
  }
  const key = (v: Violation) => `${v.material}|${v.param}|${v.code}`;
  violations.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
  return { violations, paramCount, exposedCount };
}

export function perturbFirstNumeric(materials: Material[], delta: number): { materials: Material[]; what: string } {
  const clone: Material[] = JSON.parse(JSON.stringify(materials));
  for (const m of clone) for (const p of m.params) {
    if (p.type !== "color" && p.type !== "bool" && p.max !== null) {
      p.default = [Math.min(p.default[0] + delta, p.max), ...p.default.slice(1)];
      return { materials: clone, what: `${m.id}.${p.name}` };
    }
  }
  return { materials: clone, what: "" };
}

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

// file: core/test/ta06_params.test.ts
// tier: P
// verify: node --test 'core/test/ta06_*.test.ts'
// 跨语言对照:读 sim/ta06_params.py 落盘的 out/ta06_params.json,断言 TS 侧的参数表校验与摘要与之一致;负例是核心——改一个默认值必须被摘要检测到,越界默认值 / 缺 reason / 非法类型必须报错。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validate, digestOf, perturbFirstNumeric } from "../src/ta06/material.ts";
import type { ParamConfig } from "../src/ta06/material.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta06_material.json"), "utf8")) as ParamConfig;
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta06_params.json"), "utf8"));

test("跨语言:TS 校验与 Python 产物逐位一致(参数数/暴露数/违例集合/摘要)", () => {
  const mine = validate(cfg);
  assert.equal(mine.paramCount, py.paramCount);
  assert.equal(mine.exposedCount, py.exposedCount);
  assert.deepEqual(mine.violations, py.violations);
  assert.equal(digestOf(cfg.materials), py.defaultsDigest);
  assert.equal(digestOf(cfg.materials), cfg.baselineDigest);
  assert.equal(py.defaultsChanged, false);
  console.log(`material digest = ${digestOf(cfg.materials)}`);
});

test("正例:八件套齐全、默认值落在区间内,全表零违例", () => {
  assert.equal(validate(cfg).violations.length, 0);
  assert.equal(cfg.materials.length, 3);
  assert.equal(validate(cfg).paramCount, 10);
  assert.equal(validate(cfg).exposedCount, 9);
});

test("负例:改默认值摘要必变;越界默认值 / 缺 reason / 非法类型必 fail", () => {
  const { materials, what } = perturbFirstNumeric(cfg.materials, 0.01);
  assert.equal(what, "M_Floor_Tile._Roughness");
  assert.notEqual(digestOf(materials), digestOf(cfg.materials));
  assert.equal(digestOf(materials), py.perturbedDigest);
  const bad = JSON.parse(JSON.stringify(cfg)) as ParamConfig;
  bad.materials[0].params[1].default = [1.4]; // _Roughness 上界 1,越界
  bad.materials[0].params[2].reason = ""; // _Metallic 清空 reason
  (bad.materials[0].params[3] as { type: string }).type = "mat3"; // _TilingScale 非法类型
  const codes = validate(bad).violations.map((v) => `${v.material}.${v.param}:${v.code}`);
  assert.ok(codes.includes("M_Floor_Tile._Roughness:E_DEFAULT_RANGE"), codes.join(" "));
  assert.ok(codes.includes("M_Floor_Tile._Metallic:E_NO_REASON"), codes.join(" "));
  assert.ok(codes.includes("M_Floor_Tile._TilingScale:E_BAD_TYPE"), codes.join(" "));
});

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

material digest = 888ebde73d2808cc
✔ 跨语言:TS 校验与 Python 产物逐位一致(参数数/暴露数/违例集合/摘要)
✔ 正例:八件套齐全、默认值落在区间内,全表零违例
✔ 负例:改默认值摘要必变;越界默认值 / 缺 reason / 非法类型必 fail
ℹ tests 13
ℹ pass 13
ℹ fail 0

material digest = 888ebde73d2808cc,与 Python 侧逐位一致——整数计数与字符串摘要都是精确类,两侧结果相同。边界:真实材质在 DCC / 引擎里怎么存、怎么序列化,需真实环境,命令行不可复现(本机无 DCC、无引擎),标 X;本节校验的是参数表这份数据本身。摘要只覆盖 (material, param, default),改 min / max 不会被摘要抓到——那属于「放宽/收紧契约」,应走评审而非静默检测。

阅读导航:上一节:5.3 图集变更的传播与回归 · 下一节:6.2 参数对齐与打包 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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