3.1 导入设置与 .meta 语义
第 2 章把资产管线的四阶段(DCC → 导出 → 导入 → 运行时)摆开了,本章只盯住第三阶段。导入值得单独一章,是因为它是整条链上唯一一个「美术交什么、引擎就吃什么」的自动环节:DCC 里画错了还有评审拦,运行时用错了还有 QA 拦,而导入这一步如果设置错了,产物会安静地以错误的尺寸、错误的格式、错误的压缩方式进包,直到某个下午有人发现「怎么这张图糊了」。
划界先说清。纹理压缩格式的硬件语义、运行时如何采样、mip 如何在 GPU 上选取,属于渲染原理,是 纹理与显存
与 glTF 资产管线
的家;导入器怎么在引擎里实现、导入队列怎么调度,属于引擎实现,是 《渲染引擎》的资产管线入口
的家。本节不重推这两者,只做导入这一步的规范与闸门:.meta 里哪些字段是契约、哪些是可推导的冗余、怎么用一段能跑的脚本把它们分开。
本节的核心主张只有一句:一个 .meta 字段值不值得存,看删掉它之后能不能重算出来。
3.1.1 导入这一步为什么值得立契约
《星屑旅店》是等距经营 × 装扮的休闲游戏,美术资产分家具图标、角色头像、UI、地块、特效五类,六周里三个人产出上千个文件。这些文件不会直接进包,中间隔着一层导入设置:一张 512×512 的 PNG 要告诉引擎「按 texture2d 导入、最大尺寸 2048、不压缩、clamp 寻址」。
问题在于这些设置存哪儿。多数引擎(Unity、Godot、Unreal)的做法是侧车文件:furn_lamp-base_v01.png 旁边有一个 furn_lamp-base_v01.png.meta(Godot 叫 .import),里面是一份 JSON 或 YAML。它是资产在项目里的身份证——文件改名,GUID 不变,所有引用它的材质、预制体、场景都不用改。
侧车文件一旦存在,就立刻出现一个分工问题:哪些字段该写进去? 新手最容易犯的错是「能写的都写」——把文件大小、内容哈希、修改时间、人类可读名、甚至从文件名就能推出来的类型,全都塞进 .meta。后果是每一次重新导出都会改写 .meta:美术只是重存了一遍 PNG(像素一个没变),fileBytes 变了、modifiedAt 变了、contentHash 变了,版本库里立刻多出一堆「改动」,评审要在噪声里找真改动。
3.1.2 .meta 是侧车文件,不是缩略图
先把 .meta 和它容易混淆的东西分开:
| 东西 | 位置 | 谁生成 | 能否手改 |
|---|---|---|---|
.meta / .import | 源文件旁边 | 导入器 | 能,且必须进版本库 |
导入产物(.asset / 导入缓存) | 引擎缓存目录 | 导入器 | 不能,删了会重建 |
| 缩略图 / 预览 | 缓存目录 | 导入器 | 不能,随时可丢 |
| 资产清单(manifest) | 美术仓库 | 人 / 工具 | 能,见卷一 6.1 |
关键区别是可重建性:缩略图和导入产物删掉能重建,所以它们不进版本库;而 .meta 里的 GUID 删掉就找不回来了——重建会生成一个新 GUID,所有引用它的地方全部断链。这就是为什么 .meta 必须进版本库,而缓存不必。另外,.meta 与卷一 6.1 资产清单的字段设计
里那份清单不是一回事:清单是跨资产的账(谁引用了谁、占多少预算),.meta 是单资产的账(这个文件怎么导入)。
3.1.3 判据:删掉后能不能重算
「重算」的输入只有三样:源文件字节 ∪ 命名规则(卷一 5.1)∪ 项目规范(视觉规范与预算表)。于是判据是:
契约字段(contract):删掉它之后,无法从上述三份输入重算出来的字段。
派生字段(derived):能重算出来的字段。写进.meta是冗余,写错了就是伪变更。
把《星屑旅店》的 .meta 字段按这条判据过一遍:
| 字段 | 类别 | 重算来源 | 缺失/写错的后果 |
|---|---|---|---|
guid | contract(identity) | 不可重算 | 引用断链,必须从版本库历史找回 |
importer | contract(setting) | 不可重算 | 不知道用哪个导入器,产物类型不确定 |
maxSize | contract(setting) | 不可重算 | 尺寸上限回落到导入器默认值,可能超预算 |
compression | contract(setting) | 不可重算 | 压缩方式回落到默认,显存账失真 |
wrapMode | contract(setting) | 不可重算 | 平铺地块出现接缝 |
sourceWidth / sourceHeight | derived | 源文件字节 | 与真值不符 → 伪变更 |
fileBytes | derived | 源文件字节 | 同上 |
contentHash | derived | 源文件字节 | 同上 |
modifiedAt | derived | 文件系统 mtime | 同上 |
humanName | derived | 文件名 | 同上 |
注意 compression 与 wrapMode 是非必需契约字段(required: false):它们可以缺省、由导入器默认值补上,但一旦显式写了,就必须与项目规范一致。而 guid / importer / maxSize 是必需的,缺了就是错——只是错的严重程度不同,这一点留到 3.3 讲分级。
3.1.4 D 层:把判据写成一份 schema
判据要能被机器执行,就得先落成数据。下面是 D 层配置文件 config/ta03_meta_schema.json,它不产出数字,只定义「哪个字段属于哪一类、缺了算不算错」:
{
"ruleSet": "meta",
"specVersion": 1,
"note": "导入侧车文件(.meta)的字段契约。判据:删掉该字段后无法从「源文件字节 + 命名规则 + 项目规范」重算出来的,才是契约字段(contract);能重算的一律是派生字段(derived),写进 .meta 只会制造伪变更。",
"policy": {"derivedDriftAllowed": 0, "note": "派生字段存储值与重算值不等即算漂移;契约字段缺失按角色分级"},
"fields": [
{"name": "guid", "class": "contract", "role": "identity", "required": true},
{"name": "importer", "class": "contract", "role": "setting", "required": true},
{"name": "maxSize", "class": "contract", "role": "setting", "required": true},
{"name": "compression", "class": "contract", "role": "setting", "required": false},
{"name": "wrapMode", "class": "contract", "role": "setting", "required": false},
{"name": "sourceWidth", "class": "derived", "recomputeFrom": "width"},
{"name": "sourceHeight", "class": "derived", "recomputeFrom": "height"},
{"name": "fileBytes", "class": "derived", "recomputeFrom": "bytes"},
{"name": "contentHash", "class": "derived", "recomputeFrom": "sha1"},
{"name": "modifiedAt", "class": "derived", "recomputeFrom": "mtime"},
{"name": "humanName", "class": "derived", "recomputeFrom": "basename"}
]
}
recomputeFrom 是关键字段:它把「派生」从一句形容词变成一条可执行的重算指令——sourceWidth 从源文件的 width 重算,contentHash 从 sha1 重算。没有这个键的字段就是契约字段,因为没有任何东西可以拿来重算它。审计的输入是 config/ta03_import_records.json,9 条导入记录;每条记录分两半:meta 是侧车文件里已经存着的内容,source 是从源文件字节直接读出来的真值。两半刻意不完全一致,好让审计真的报出东西。
3.1.5 S 层:字段分类与漂移审计
下面这段是 S 层脚本,读上面那份 schema 与 9 条记录,把每个字段出现分到三类,再判契约缺失与派生漂移,落 out/ta03_meta.json:
#!/usr/bin/env python3
# file: sim/ta03_meta.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta03_meta.py
"""3.1 的 S 层审计:把 .meta 的每个字段分到 contract / derived / unknown 三类,再判契约字段
有没有缺、派生字段的存储值与源文件真值是否漂移。落 out/ta03_meta.json 供 P 层跨语言对照。"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
CFG = os.path.join(ROOT, "config")
def audit(schema: dict, records: list) -> tuple:
by_name = {f["name"]: f for f in schema["fields"]}
counts = {"contract": 0, "derived": 0, "unknown": 0}
issues = []
for r in sorted(records, key=lambda x: x["id"]):
meta, src = r["meta"], r["source"]
for name in sorted(meta):
f = by_name.get(name)
if f is None:
counts["unknown"] += 1
issues.append({"id": r["id"], "code": "I_META_UNKNOWN_FIELD", "field": name})
elif f["class"] == "contract":
counts["contract"] += 1
else:
counts["derived"] += 1
k = f.get("recomputeFrom")
if k and k in src and str(meta[name]) != str(src[k]):
issues.append({"id": r["id"], "code": "W_META_DERIVED_DRIFT", "field": name,
"stored": str(meta[name]), "actual": str(src[k])})
for f in schema["fields"]:
if f["class"] == "contract" and f["required"] and f["name"] not in meta:
issues.append({"id": r["id"], "field": f["name"],
"code": "E_META_MISSING_IDENTITY" if f["role"] == "identity"
else "E_META_MISSING_SETTING"})
issues.sort(key=lambda v: (v["id"], v["code"], v["field"]))
return counts, issues
def main() -> int:
schema = json.load(open(f"{CFG}/ta03_meta_schema.json", encoding="utf-8"))
records = json.load(open(f"{CFG}/ta03_import_records.json", encoding="utf-8"))["records"]
counts, issues = audit(schema, records)
keys = [f"{v['id']}:{v['code']}:{v['field']}"
+ (f":{v['stored']}->{v['actual']}" if "stored" in v else "") for v in issues]
digest = hashlib.sha256("\n".join(keys).encode("utf-8")).hexdigest()[:16]
report = {"project": "stardust-inn", "ruleSet": schema["ruleSet"], "recordCount": len(records),
"fieldOccurrences": sum(counts.values()), "counts": counts, "digest": digest,
"missingContract": sum(1 for v in issues if v["code"].startswith("E_META_MISSING")),
"driftCount": sum(1 for v in issues if v["code"] == "W_META_DERIVED_DRIFT"),
"unknownCount": sum(1 for v in issues if v["code"] == "I_META_UNKNOWN_FIELD"),
"issueCount": len(issues), "issues": issues}
os.makedirs(OUT, exist_ok=True)
with open(f"{OUT}/ta03_meta.json", "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"recordCount={len(records)} fieldOccurrences={report['fieldOccurrences']} "
f"contract={counts['contract']} derived={counts['derived']} unknown={counts['unknown']}")
print(f"missingContract={report['missingContract']} drift={report['driftCount']} "
f"unknownField={report['unknownCount']} issues={len(issues)} digest={digest}")
for v in issues:
print(f" {v['code']} {v['id']} {v['field']}"
+ (f" stored={v['stored']} actual={v['actual']}" if "stored" in v else ""))
print("wrote out/ta03_meta.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta03_meta.py(只摘 3 条,完整 8 条见 out/ta03_meta.json):
recordCount=9 fieldOccurrences=98 contract=43 derived=54 unknown=1
missingContract=2 drift=5 unknownField=1 issues=8 digest=693257bfa48a6230
E_META_MISSING_SETTING char_innkeeper-idle_v01 maxSize
W_META_DERIVED_DRIFT furn_chair-base_v01 sourceWidth stored=512 actual=300
E_META_MISSING_IDENTITY vfx_steam-puff_v01 guid
wrote out/ta03_meta.json
三行数字值得逐个看。fieldOccurrences=98 = 9 条记录 × 11 字段,再减掉 1 个缺失(char_innkeeper-idle_v01 少 maxSize)、加上 1 个未知字段(furn_rug-round_v01 多 pivotHint)。契约字段 43 个、派生字段 54 个——也就是说这份 .meta 里一半以上的内容是可以重算的。这正是「伪变更」的来源:只要源文件被重存一遍,那 54 个字段里就有 5 个立刻与真值不符。
drift=5 就是这 5 处,它们都是真的会制造假 diff 的类型:furn_chair-base_v01 的 sourceWidth 存的是 512 而实际是 300(画布改小后 .meta 没跟着更新);ui_panel-wood_v01 的 fileBytes 存的是 20480 而实际 65536(典型的「重导一遍就变」);icon_Icon-star_v01 的 contentHash 是 sha1:0000 这个没写全的占位值;tile_grass-dark_v01 的 modifiedAt 停在 2026-10-01 而 mtime 已经是 2026-10-11;icon_dot-bullet_v01 的 humanName 是 dot-bullet 而文件名是 icon_dot-bullet_v01。
而 missingContract=2 是另一种病:char_innkeeper-idle_v01 缺 maxSize(导入设置回落到默认值),vfx_steam-puff_v01 缺 guid(身份丢了,这是最重的一处,3.3 会把它判为阻断)。
3.1.6 P 层:同一套判据的 TS 内核
S 层是 Python,P 层是 TypeScript——两边不共享源码,只共享同一份 config/。下面的 TS 内核与 sim/ta03_meta.py 读的是同一份 schema 与同一份记录:
// file: core/src/ta03/meta.ts
// tier: P
// verify: node --test 'core/test/ta03_*.test.ts'
// 3.1 的 P 层内核:与 sim/ta03_meta.py 共用同一份 config/ta03_meta_schema.json 与
// config/ta03_import_records.json。字段分类与漂移判据全是字符串比较,故两侧问题集合逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type MetaField = { name: string; class: string; role?: string; required?: boolean; recomputeFrom?: string };
export type MetaSchema = { fields: MetaField[] };
export type MetaRecord = { id: string; meta: Record<string, unknown>; source: Record<string, unknown> };
export type MetaIssue = { id: string; code: string; field: string; stored?: string; actual?: string };
export type MetaCounts = { contract: number; derived: number; unknown: number };
const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
export function auditMeta(schema: MetaSchema, records: MetaRecord[]): { counts: MetaCounts; issues: MetaIssue[] } {
const byName = new Map<string, MetaField>();
for (const f of schema.fields) byName.set(f.name, f);
const counts: MetaCounts = { contract: 0, derived: 0, unknown: 0 };
const issues: MetaIssue[] = [];
for (const r of [...records].sort((a, b) => cmp(a.id, b.id))) {
for (const name of Object.keys(r.meta).sort()) {
const f = byName.get(name);
if (f === undefined) {
counts.unknown += 1;
issues.push({ id: r.id, code: "I_META_UNKNOWN_FIELD", field: name });
} else if (f.class === "contract") {
counts.contract += 1;
} else {
counts.derived += 1;
const key = f.recomputeFrom;
if (key !== undefined && key in r.source && String(r.meta[name]) !== String(r.source[key])) {
issues.push({ id: r.id, code: "W_META_DERIVED_DRIFT", field: name,
stored: String(r.meta[name]), actual: String(r.source[key]) });
}
}
}
for (const f of schema.fields) {
if (f.class === "contract" && f.required === true && !(f.name in r.meta)) {
const code = f.role === "identity" ? "E_META_MISSING_IDENTITY" : "E_META_MISSING_SETTING";
issues.push({ id: r.id, code, field: f.name });
}
}
}
issues.sort((x, y) => cmp(x.id, y.id) || cmp(x.code, y.code) || cmp(x.field, y.field));
return { counts, issues };
}
/** 与 sim/ta03_meta.py 的 key 逐字一致,用于跨语言 digest。 */
export function issueKey(v: MetaIssue): string {
const k = `${v.id}:${v.code}:${v.field}`;
return v.stored === undefined ? k : `${k}:${v.stored}->${v.actual}`;
}
跨语言对照不能靠「两边写得像」,得靠读对方的产物。测试读的是 sim/ta03_meta.py 落盘的 out/ta03_meta.json(不是同一份源码),再断言 TS 复算出的问题集合与它逐条相同:
// file: core/test/ta03_meta.test.ts
// tier: P
// verify: node --test 'core/test/ta03_*.test.ts'
// 跨语言对照:读 sim/ta03_meta.py 落盘的 out/ta03_meta.json,断言 TS 侧 auditMeta 复算出的
// 字段分类与问题集合与之一致(字符串/整数类,逐位一致)。负例见后两条测试。
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 { auditMeta, issueKey } from "../src/ta03/meta.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const schema = JSON.parse(readFileSync(resolve(ROOT, "config/ta03_meta_schema.json"), "utf8"));
const records = JSON.parse(readFileSync(resolve(ROOT, "config/ta03_import_records.json"), "utf8")).records;
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta03_meta.json"), "utf8"));
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS 字段分类与问题集合 == Python(逐位一致)", () => {
const mine = auditMeta(schema, records);
const theirs = (py.issues as { id: string; code: string; field: string }[]).map(issueKey);
assert.deepEqual(mine.counts, py.counts);
assert.deepEqual(mine.issues.map(issueKey), theirs);
assert.equal(digest(mine.issues.map(issueKey)), py.digest);
console.log(`meta digest = ${py.digest}`);
});
test("计数:98 = contract 43 + derived 54 + unknown 1;干净记录不报", () => {
const { counts, issues } = auditMeta(schema, records);
assert.deepEqual(counts, { contract: 43, derived: 54, unknown: 1 });
assert.equal(counts.contract + counts.derived + counts.unknown, py.fieldOccurrences);
assert.deepEqual(issues.filter((v) => v.id === "furn_lamp-base_v01"), []);
});
test("负例:派生字段漂移必须被检出(存储值 != 源文件真值)", () => {
const drift = auditMeta(schema, records).issues.filter((v) => v.code === "W_META_DERIVED_DRIFT");
assert.equal(drift.length, py.driftCount);
assert.ok(drift.some((v) => v.id === "furn_chair-base_v01" && v.field === "sourceWidth"
&& v.stored === "512" && v.actual === "300"), "sourceWidth 漂移未报");
assert.ok(drift.some((v) => v.id === "ui_panel-wood_v01" && v.field === "fileBytes"), "fileBytes 漂移未报");
});
test("负例:契约字段缺失按角色分级;未知字段报 info", () => {
const { issues } = auditMeta(schema, records);
assert.ok(issues.some((v) => v.id === "vfx_steam-puff_v01" && v.code === "E_META_MISSING_IDENTITY"));
assert.ok(issues.some((v) => v.id === "char_innkeeper-idle_v01" && v.code === "E_META_MISSING_SETTING"));
assert.deepEqual(issues.filter((v) => v.code === "I_META_UNKNOWN_FIELD").map((v) => v.field), ["pivotHint"]);
});
实跑 node --test 'core/test/ta03_*.test.ts'(下面只列本节相关的 4 条;本章三节合计 12 条):
meta digest = 693257bfa48a6230
✔ 跨语言:TS 字段分类与问题集合 == Python(逐位一致)
✔ 计数:98 = contract 43 + derived 54 + unknown 1;干净记录不报
✔ 负例:派生字段漂移必须被检出(存储值 != 源文件真值)
✔ 负例:契约字段缺失按角色分级;未知字段报 info
ℹ pass 12
ℹ fail 0
两侧算出的 digest 都是 693257bfa48a6230。这里没有浮点——字段名、类别、字符串值全是整数或字符串,所以是逐位一致,而不是「容差内一致」(后者是色彩派生量的口径,见卷一第 4 章)。
3.1.7 三个常被争论的点
GUID 到底要不要进 .meta。 有的引擎把 GUID 存在集中式的资产数据库里,.meta 里只放导入设置。两者的取舍是合并冲突的形状:GUID 进 .meta 时,冲突是「同一文件两侧各改一行」;集中式数据库时,冲突是「同一个数据库文件两侧各改一段」,更糟。我们的选择是进 .meta,与 Unity 一致。
派生字段既然能重算,能不能干脆不存。 能,而且更好。真正需要 .meta 里存 fileBytes 的场景只有一种:你想在不读源文件的前提下做预算盘点。即便如此,也应该把它标成「缓存」而不是「事实」——一旦它与真值不符,正确动作是重算,不是人工去改。
humanName 这种给人看的字段该放哪。 放清单(卷一 6.1)里,不放 .meta 里。.meta 是给导入器读的,导入器不需要「人类可读名」;把它写进 .meta,等于给文件改名这条操作凭空加了一个必须同步的副本。pivotHint 那条未知字段(I_META_UNKNOWN_FIELD)就是这条纪律的反面教材:它是某个美术自己加的注释,schema 里没有、导入器也不读,留着只会让 schema 与现实的差距越来越大。3.3 会把它判为 info,既不阻断也不假装它不存在。
下一节把「字段对不对」换成「值合不合规」:尺寸、格式、命名三类判据要写成能跑的规则引擎,而不是一张检查清单。
阅读导航:上一节:2.3 X 范式:真实 DCC 不可复现 · 下一节:3.2 导入期校验:尺寸、格式、命名 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。