9.2 混合空间与过渡规范
9.1
把片段绑到了状态,但状态不是瞬移——从 Idle 进 Walk 要几帧、从 Walk 回 Idle 能不能中途打断、移动速度从 0 加到 300 时几个片段怎么分配权重,这些都要先写成规范,否则每个人在引擎里各调各的。
混合树的求值、过渡的采样、根运动的重定向属于实现,本节外链 《渲染引擎》 与 《骨骼动画》 ,只做资产侧的两件事:混合空间的轴 / 样本 / 权重,以及过渡表的帧数 / 退出时刻 / 可打断。这两样都是纯数据,能在打包前逐条校验。
9.2.1 2D 混合空间:轴、样本、双线性权重
移动不是一维的:快慢之外还有左右倾斜。所以《星屑旅店》的 Locomotion 混合空间是 2D 的,轴是 speed ∈ [0,300] 与 lean ∈ [-100,100],四个角样本各挂一个片段(慢/快 × 左倾/右倾)。查询点落在矩形内时,权重由双线性插值给出。
关键纪律:权重一律整数千分比。tx = (qx-minX)*1000//(maxX-minX)、ty 同理,四角权重是 ((cx?tx:1000-tx)*(cy?ty:1000-ty))//1000,全程整数地板除。于是 Python 与 TS 逐位一致——这也是全章唯一能写「逐位一致」的量,它是整数类,不是色彩派生量。
9.2.2 过渡规范:帧数、退出时刻、可打断
每条过渡写四样:from / to(必须是已知状态)、frames(过渡帧数,须落在预算区间内)、exitPermille(从源片段的哪个千分位开始允许退出,0 表示随时)、interruptible(能否被更高优先级状态打断)。这四样加起来决定了「手感」,但判断它们合不合法不需要引擎:帧数超预算、to 指向一个不存在的状态,都是静态可查的。
9.2.3 D 层:混合空间与过渡样本
下面这份是 D 层样本,四个角样本 + 7 条过渡,故意埋了一条悬空过渡(Sit→Ghost)与一条超预算过渡(Work→Idle 30 帧 > 上限 24 帧):
{
"project": "stardust-inn",
"chapter": 9,
"note": "9.2 混合空间与过渡规范样本:2D 移动混合空间(speed × lean,四角样本 + 双线性整数权重)+ 7 条过渡。故意埋 1 条悬空过渡(Sit→Ghost,Ghost 不是状态)、1 条超预算过渡(Work→Idle 30 帧 > 上限 24 帧)。",
"blendSpace": {
"name": "Locomotion",
"axes": [
{ "param": "speed", "min": 0, "max": 300 },
{ "param": "lean", "min": -100, "max": 100 }
],
"samples": [
{ "clip": "innkeeper_idle_lean_l", "x": 0, "y": -100 },
{ "clip": "innkeeper_idle_lean_r", "x": 0, "y": 100 },
{ "clip": "innkeeper_run_lean_l", "x": 300, "y": -100 },
{ "clip": "innkeeper_run_lean_r", "x": 300, "y": 100 }
]
},
"clipSet": ["innkeeper_idle_lean_l", "innkeeper_idle_lean_r", "innkeeper_run_lean_l", "innkeeper_run_lean_r", "innkeeper_work"],
"queryPoints": [
{ "speed": 0, "lean": -100 }, { "speed": 300, "lean": 100 },
{ "speed": 150, "lean": 0 }, { "speed": 100, "lean": 50 }
],
"states": ["Idle", "Walk", "Work", "Sit", "Celebrate"],
"transitionBudget": { "minFrames": 4, "maxFrames": 24 },
"transitions": [
{ "from": "Idle", "to": "Walk", "frames": 8, "exitPermille": 0, "interruptible": true },
{ "from": "Walk", "to": "Idle", "frames": 12, "exitPermille": 800, "interruptible": false },
{ "from": "Walk", "to": "Work", "frames": 10, "exitPermille": 1000, "interruptible": true },
{ "from": "Work", "to": "Idle", "frames": 30, "exitPermille": 1000, "interruptible": false },
{ "from": "Idle", "to": "Celebrate", "frames": 6, "exitPermille": 0, "interruptible": true },
{ "from": "Celebrate", "to": "Idle", "frames": 20, "exitPermille": 1000, "interruptible": false },
{ "from": "Sit", "to": "Ghost", "frames": 8, "exitPermille": 0, "interruptible": true }
]
}
9.2.4 S 层:双线性权重与过渡校验
下面这段是 S 层脚本,算四个查询点的整数权重,再逐条校验过渡:
#!/usr/bin/env python3
# file: sim/ta09_blend.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta09_blend.py
"""9.2 混合空间与过渡规范的 S 层校验器:读 config/ta09_blend.json。
2D 四角样本的双线性权重一律整数千分比、整数地板除,故 Python 与 TS **逐位一致**。过渡校验:
from / to 必须是已知状态、帧数落在 ``transitionBudget`` 内、``(from,to)`` 不重复。
落 ``out/ta09_blend.json`` 供 P 层对照。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def bilinear(samples, axes, qx, qy):
"""2D 四角双线性整数权重,返回 [(clip, permille), ...]。"""
(ax0, ax1), (ay0, ay1) = axes
tx = (qx - ax0) * 1000 // (ax1 - ax0)
ty = (qy - ay0) * 1000 // (ay1 - ay0)
return [(s["clip"], (tx if s["x"] == ax1 else 1000 - tx)
* (ty if s["y"] == ay1 else 1000 - ty) // 1000) for s in samples]
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/ta09_blend.json"), encoding="utf-8"))
bs = spec["blendSpace"]
axes = [(a["min"], a["max"]) for a in bs["axes"]]
(ax0, ax1), (ay0, ay1) = axes
samples, states, budget = bs["samples"], set(spec["states"]), spec["transitionBudget"]
need = {(x, y) for x in (ax0, ax1) for y in (ay0, ay1)}
bad = ["cornersMissing"] if {(s["x"], s["y"]) for s in samples} != need else []
bad += [f"outOfRange={s['clip']}" for s in samples
if not (ax0 <= s["x"] <= ax1 and ay0 <= s["y"] <= ay1)]
missing = sorted({s["clip"] for s in samples} - set(spec["clipSet"]))
dangling, out_of_budget, seen, dup = [], [], set(), []
for t in spec["transitions"]:
if t["from"] not in states or t["to"] not in states:
dangling.append(f"{t['from']}->{t['to']}")
if not (budget["minFrames"] <= t["frames"] <= budget["maxFrames"]):
out_of_budget.append(f"{t['from']}->{t['to']}:{t['frames']}")
key = (t["from"], t["to"])
dup += [f"{t['from']}->{t['to']}"] if key in seen else []
seen.add(key)
weights = []
for q in spec["queryPoints"]:
parts = [f"{c}={w}" for c, w in bilinear(samples, axes, q["speed"], q["lean"]) if w > 0]
weights.append(f"speed={q['speed']} lean={q['lean']}:" + " ".join(parts))
tlines = sorted(f"{t['from']}->{t['to']}:{t['frames']}:{t['exitPermille']}:"
f"{'T' if t['interruptible'] else 'F'}" for t in spec["transitions"])
wd = hashlib.sha256("\n".join(weights).encode("utf-8")).hexdigest()[:16]
td = hashlib.sha256("\n".join(tlines).encode("utf-8")).hexdigest()[:16]
rep = {"project": spec["project"], "blendSpace": bs["name"], "sampleCount": len(samples),
"queryCount": len(spec["queryPoints"]), "transitionCount": len(spec["transitions"]),
"badSamples": bad, "missingClipRefs": missing, "danglingTransitions": sorted(dangling),
"outOfBudget": sorted(out_of_budget), "duplicateTransitions": sorted(dup),
"weights": weights, "weightDigest": wd, "transitionDigest": td}
with open(os.path.join(ROOT, "out/ta09_blend.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"samples={len(samples)} queries={len(weights)} transitions={len(spec['transitions'])}")
print(f"dangling={len(dangling)} outOfBudget={len(out_of_budget)} dup={len(dup)} "
f"badSamples={len(bad)} missingRefs={len(missing)}")
for w in weights:
print(f" {w}")
for t in sorted(out_of_budget):
print(f" outOfBudget {t}")
for t in sorted(dangling):
print(f" dangling {t}")
print(f"weightDigest={wd}")
print(f"transitionDigest={td}")
print("wrote out/ta09_blend.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta09_blend.py:
samples=4 queries=4 transitions=7
dangling=1 outOfBudget=1 dup=0 badSamples=0 missingRefs=0
speed=0 lean=-100:innkeeper_idle_lean_l=1000
speed=300 lean=100:innkeeper_run_lean_r=1000
speed=150 lean=0:innkeeper_idle_lean_l=250 innkeeper_idle_lean_r=250 innkeeper_run_lean_l=250 innkeeper_run_lean_r=250
speed=100 lean=50:innkeeper_idle_lean_l=166 innkeeper_idle_lean_r=500 innkeeper_run_lean_l=83 innkeeper_run_lean_r=249
outOfBudget Work->Idle:30
dangling Sit->Ghost
weightDigest=b3017af58f0ce3e5
transitionDigest=62666f7852d7906b
wrote out/ta09_blend.json
两个角点查询的权重是干净的 1000 / 0;中心点 (150,0) 四角各 250;非整除的 (100,50) 给出 166/500/83/249——四项相加是 998,不是 1000。这不是 bug,是整数地板除的必然:每一段都往下取整,误差只会往下跑。规范只承诺「权重是确定的整数」,归一化交给运行时。
9.2.5 P 层:同一套整数算法的 TS 内核
下面这段是 P 层 TS 内核,bilinear 与上面的 Python 逐位对应,同样全程 Math.floor:
// file: core/src/ta09/blend.ts
// tier: P
// verify: node --test 'core/test/ta09_*.test.ts'
// 9.2 混合空间与过渡规范的 P 层内核。与 sim/ta09_blend.py 同一套整数算法:2D 四角样本的
// 双线性权重一律用整数地板除(Math.floor(a / b),a、b 为整数),故两侧**逐位一致**。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";
export type Sample = { clip: string; x: number; y: number };
export function bilinear(samples: Sample[], axes: any, qx: number, qy: number): [string, number][] {
const [[ax0, ax1], [ay0, ay1]] = axes;
const tx = Math.floor(((qx - ax0) * 1000) / (ax1 - ax0));
const ty = Math.floor(((qy - ay0) * 1000) / (ay1 - ay0));
return samples.map((s) => {
const wx = s.x === ax1 ? tx : 1000 - tx;
const wy = s.y === ay1 ? ty : 1000 - ty;
return [s.clip, Math.floor((wx * wy) / 1000)] as [string, number];
});
}
const digest16 = (lines: string[]): string =>
createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
export function analyze(spec: any) {
const bs = spec.blendSpace;
const axes: [[number, number], [number, number]] = [[bs.axes[0].min, bs.axes[0].max],
[bs.axes[1].min, bs.axes[1].max]];
const [[ax0, ax1], [ay0, ay1]] = axes;
const samples: Sample[] = bs.samples;
const states = new Set<string>(spec.states);
const budget = spec.transitionBudget;
const need: string[] = [];
for (const x of [ax0, ax1]) for (const y of [ay0, ay1]) need.push(`${x},${y}`);
const have = new Set(samples.map((s) => `${s.x},${s.y}`));
const bad: string[] = [];
if (need.some((c) => !have.has(c))) bad.push("cornersMissing");
for (const s of samples) {
if (!(ax0 <= s.x && s.x <= ax1 && ay0 <= s.y && s.y <= ay1)) bad.push(`outOfRange=${s.clip}`);
}
const clipSet = new Set<string>(spec.clipSet);
const missing = [...new Set(samples.map((s) => s.clip))].filter((c) => !clipSet.has(c)).sort();
const dangling: string[] = [];
const outOfBudget: string[] = [];
const dup: string[] = [];
const seen = new Set<string>();
for (const t of spec.transitions) {
if (!states.has(t.from) || !states.has(t.to)) dangling.push(`${t.from}->${t.to}`);
if (!(budget.minFrames <= t.frames && t.frames <= budget.maxFrames)) {
outOfBudget.push(`${t.from}->${t.to}:${t.frames}`);
}
const key = `${t.from}->${t.to}`;
if (seen.has(key)) dup.push(key);
seen.add(key);
}
const weights = (spec.queryPoints as { speed: number; lean: number }[]).map((q) => {
const parts = bilinear(samples, axes, q.speed, q.lean)
.filter(([, w]) => w > 0)
.map(([c, w]) => `${c}=${w}`);
return `speed=${q.speed} lean=${q.lean}:` + parts.join(" ");
});
const tlines = (spec.transitions as { from: string; to: string; frames: number;
exitPermille: number; interruptible: boolean }[])
.map((t) => `${t.from}->${t.to}:${t.frames}:${t.exitPermille}:${t.interruptible ? "T" : "F"}`)
.sort();
return {
sampleCount: samples.length, queryCount: spec.queryPoints.length,
transitionCount: spec.transitions.length, badSamples: bad, missingClipRefs: missing,
danglingTransitions: dangling.sort(), outOfBudget: outOfBudget.sort(),
duplicateTransitions: dup.sort(), weights,
weightDigest: digest16(weights), transitionDigest: digest16(tlines),
};
}
测试读 Python 落盘的 out/ta09_blend.json,并断言地板除造成的「权重之和不足 1000」是确定行为:
// file: core/test/ta09_blend.test.ts
// tier: P
// verify: node --test 'core/test/ta09_*.test.ts'
// 跨语言对照:读 sim/ta09_blend.py 落盘的 out/ta09_blend.json,断言 TS 侧的双线性整数权重
// 与过渡校验与之一致(整数千分比类,逐位一致)。负例:悬空过渡必须报出;地板除使权重之和
// 可能不等于 1000,规范只保证整数权重确定,不保证归一化。
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 { analyze, bilinear } from "../src/ta09/blend.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/ta09_blend.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta09_blend.json"), "utf8"));
test("跨语言:TS 权重表与过渡账 == Python(逐位一致)", () => {
const r = analyze(spec);
assert.deepEqual(r.weights, py.weights);
assert.equal(r.weightDigest, py.weightDigest);
assert.equal(r.transitionDigest, py.transitionDigest);
assert.deepEqual(r.danglingTransitions, py.danglingTransitions);
assert.deepEqual(r.outOfBudget, py.outOfBudget);
assert.deepEqual(r.duplicateTransitions, py.duplicateTransitions);
console.log(`weight digest = ${py.weightDigest} / transition digest = ${py.transitionDigest}`);
});
test("四角样本与地板除:中心四角各 250,非整除点权重之和不足 1000", () => {
const axes: [[number, number], [number, number]] = [[0, 300], [-100, 100]];
const center = bilinear(spec.blendSpace.samples, axes, 150, 0).map(([, v]) => v);
assert.deepEqual(center, [250, 250, 250, 250]);
const corner = bilinear(spec.blendSpace.samples, axes, 300, 100).map(([, v]) => v);
assert.deepEqual(corner, [0, 0, 0, 1000]);
const w = bilinear(spec.blendSpace.samples, axes, 100, 50).map(([, v]) => v);
assert.deepEqual(w, [166, 500, 83, 249]);
assert.equal(w.reduce((a, b) => a + b, 0), 998);
});
test("负例:悬空过渡必须报出,超预算帧数必须报出", () => {
const r = analyze(spec);
assert.deepEqual(r.danglingTransitions, ["Sit->Ghost"]);
assert.deepEqual(r.outOfBudget, ["Work->Idle:30"]);
assert.deepEqual(r.duplicateTransitions, []);
});
实跑 node --test 'core/test/ta09_blend.test.ts':
weight digest = b3017af58f0ce3e5 / transition digest = 62666f7852d7906b
✔ 跨语言:TS 权重表与过渡账 == Python(逐位一致)
✔ 四角样本与地板除:中心四角各 250,非整除点权重之和不足 1000
✔ 负例:悬空过渡必须报出,超预算帧数必须报出
ℹ tests 3
ℹ pass 3
ℹ fail 0
两侧权重 digest 都是 b3017af58f0ce3e5、过渡 digest 都是 62666f7852d7906b——整数类,逐位一致。注意 bilinear 的返回顺序是样本声明顺序,不是按权重排序;测试里 deepEqual(w, [166,500,83,249]) 依赖的正是这个顺序,换了样本顺序测试就会红。
9.2.6 为什么规范只承诺「确定」,不承诺「归一」
有人会觉得 998 是个缺陷,想加一步「把差值补给最大项」。规范故意不这么做:一旦引入「补最大项」这种规则,Python 与 TS 就得逐字节同意哪一项最大、并列时怎么办,稍不留神两边就分叉。宁可在规范层只保证「同样的查询点得到同样的整数权重」,归一化作为实现细节留给运行时——运行时本来就知道引擎怎么处理余量。这条边界和 9.1 里「族只保证方向齐全、插值交给引擎」是同一个取舍。
阅读导航:上一节:9.1 动画资源命名与状态绑定 · 下一节:9.3 动画事件与逻辑一致性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。