9.2 混合空间与过渡规范

9.1 把片段绑到了状态,本节定状态之间怎么过渡、连续参数怎么在几个片段间混合。2D 四角样本的双线性权重全程整数地板除,Python 与 TS 逐位一致;7 条过渡判出悬空 1、超预算 1,非整除查询点的权重之和是 998 而非 1000,规范只承诺确定、不承诺归一。

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 动画事件与逻辑一致性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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