《游戏引擎实现原理》3.2 状态机与混合树

状态机决定「现在放哪段动画」,混合树决定「两段动画之间怎么过渡」。从零实现参数驱动的状态转移(条件、exitTime、any-state、trigger 消费)、两段姿态的交叉淡化与一维混合树权重,全部 L1 纯 TypeScript 并带 10 条单测。

3.2 状态机与混合树

3.1 的求值器只会「把一段剪辑播出来」。但游戏里没有一段动画能独立成立:站着、走、跑、跳、受击,玩家的一举一动都在这些片段之间来回切。这一节补上层的两块:状态机回答「现在该放哪段剪辑」,混合树回答「两段剪辑之间怎么过渡」。前者是离散的跳转,后者是连续的加权——合起来才是《微光岛》里主角从走到跑的那段顺滑过渡。

两者都是纯数据 + 纯逻辑,不碰 GPU,因此和 3.1 一样落在 L1:Node 直跑、单测覆盖。

交叉淡化与一维混合树

先看连续的那一半。交叉淡化(cross-fade)把两段已经求值好的姿态按权重 w 合成一段:平移与缩放逐分量线性插值,旋转走 slerp——和 3.1 采样关键帧用的是同一套数学,只是这里的「两个端点」变成了「两段姿态」。一维混合树则把参数 x 定位到相邻两段之间,用线性比例算出权重:速度从 0 涨到 5,就在 idle 与 run 之间连续过渡,而不是突然切换。

// file: src/subsys/anim/blend.ts
// tier: L1
// verify: npx vitest run src/subsys/anim/state-machine.test.ts
//
// helio 子系统卷 3.2:姿态混合。两件事——两段姿态按权重交叉淡化(cross-fade),
// 以及一维混合树按参数把 N 段姿态加权求和。平移/缩放逐分量线性插值,旋转走 slerp。

import type { PoseBuffer } from './pose.ts';
import { slerpInto } from './evaluator.ts';

/** 线性插值。 */
export function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

/**
 * 把 a、b 两段姿态按 w 混合进 out(w=0 取 a,w=1 取 b)。
 * touched 取并集:任一侧驱动过该节点,结果就视为被驱动。
 */
export function blendPose(a: PoseBuffer, b: PoseBuffer, w: number, out: PoseBuffer): void {
  if (a.count !== b.count || a.count !== out.count) {
    throw new RangeError('混合的姿态缓冲节点数必须一致');
  }
  const t = Math.min(1, Math.max(0, w));
  for (let i = 0; i < out.count; i++) {
    const t3 = i * 3;
    const r4 = i * 4;
    for (let k = 0; k < 3; k++) {
      out.translation[t3 + k] = lerp(a.translation[t3 + k]!, b.translation[t3 + k]!, t);
      out.scale[t3 + k] = lerp(a.scale[t3 + k]!, b.scale[t3 + k]!, t);
    }
    slerpInto(
      out.rotation, r4,
      a.rotation[r4]!, a.rotation[r4 + 1]!, a.rotation[r4 + 2]!, a.rotation[r4 + 3]!,
      b.rotation[r4]!, b.rotation[r4 + 1]!, b.rotation[r4 + 2]!, b.rotation[r4 + 3]!,
      t,
    );
    out.touched[i] = a.touched[i]! | b.touched[i]!;
  }
}

/** 一维混合树:把参数 x 定位到相邻两段之间的权重。 */
export interface Blend1D {
  readonly i0: number;
  readonly i1: number;
  readonly w: number;
}

/**
 * 在一串升序阈值里定位参数 x:返回左右两段的下标与右段的权重 w。
 * x 不高于首阈值时两下标都指向首段(w=0);不低于末阈值时都指向末段。
 */
export function blendWeights1D(thresholds: readonly number[], x: number): Blend1D {
  const n = thresholds.length;
  if (n === 0) throw new RangeError('混合树至少要有一个阈值');
  if (n === 1 || x <= thresholds[0]!) return { i0: 0, i1: 0, w: 0 };
  if (x >= thresholds[n - 1]!) return { i0: n - 1, i1: n - 1, w: 0 };
  for (let j = 0; j < n - 1; j++) {
    const lo = thresholds[j]!;
    const hi = thresholds[j + 1]!;
    if (x >= lo && x < hi) {
      const span = hi - lo;
      return { i0: j, i1: j + 1, w: span > 0 ? (x - lo) / span : 0 };
    }
  }
  return { i0: n - 1, i1: n - 1, w: 0 };
}

/**
 * 一维混合树求值:poses[i] 对应 thresholds[i](均已各自求值完毕),
 * 按参数 x 混合出 out。典型用法是「速度参数驱动 走→跑」这类连续过渡。
 */
export function blendTree1D(
  poses: readonly PoseBuffer[],
  thresholds: readonly number[],
  x: number,
  out: PoseBuffer,
): void {
  if (poses.length !== thresholds.length) {
    throw new RangeError('姿态数与阈值数必须一致');
  }
  const { i0, i1, w } = blendWeights1D(thresholds, x);
  if (i0 === i1) {
    blendPose(poses[i0]!, poses[i0]!, 0, out);
    return;
  }
  blendPose(poses[i0]!, poses[i1]!, w, out);
}

参数驱动的状态转移

离散的那一半是状态机。它持有一张「状态表」(每个状态一段剪辑 + 环绕方式 + 播放速度)和一张「转移表」(从哪个状态、到哪个状态、条件、交叉淡化时长)。每帧推进时做三件事:推进当前剪辑时间;若不在转移中,就扫描转移表找第一个条件满足的跳转;若在转移中,就按 duration 把权重从 0 推到 1,到 1 后把当前状态换成目标状态。

三个设计点值得说清。exitTime 是「等当前剪辑播到某个比例再走」——它让「挥拳动作至少播完 50% 才能接下一招」这类要求落地。any-state(from 写 '*')让受击、死亡这类转移从任意状态都能触发,不必逐状态连边。trigger 是一次性参数:它被某次转移的条件消费后自动复位,正好对应「按下跳跃键」这种「发生一次」的事件,而布尔参数会一直保持 true。

// file: src/subsys/anim/state-machine.ts
// tier: L1
// verify: npx vitest run src/subsys/anim/state-machine.test.ts
//
// helio 子系统卷 3.2:参数驱动的动画状态机。状态持有一段剪辑,转移由「参数条件」
// 触发,跨越转移时按 duration 做交叉淡化。支持 exitTime(等当前剪辑播到某比例再走)、
// any-state 转移('*'),以及一次性 trigger(被某次转移消费后自动复位)。

import type { Clip, LoopMode } from './clip.ts';
import { wrapTime } from './evaluator.ts';

export type ParamValue = number | boolean;
export type Comparator = '>' | '<' | '>=' | '<=' | '==' | '!=';

export interface Condition {
  readonly param: string;
  readonly op: Comparator;
  readonly threshold: number;
}

export interface Transition {
  readonly from: string;
  readonly to: string;
  readonly conditions: readonly Condition[];
  readonly duration: number;
  readonly exitTime?: number;
}

export interface AnimState {
  readonly name: string;
  readonly clip: Clip;
  readonly loop: LoopMode;
  readonly speed: number;
}

/** 每帧求值结果:两条状态各自的剪辑时间与混合权重。 */
export interface MachineOutput {
  readonly fromState: string;
  readonly toState: string;
  readonly fromTime: number;
  readonly toTime: number;
  readonly weight: number;
  readonly transitioning: boolean;
}

interface ActiveTransition {
  readonly from: string;
  readonly to: string;
  elapsed: number;
  duration: number;
  toTime: number;
}

export class AnimStateMachine {
  readonly #states: Map<string, AnimState>;
  readonly #transitions: readonly Transition[];
  readonly #params = new Map<string, ParamValue>();
  readonly #triggers = new Set<string>();
  #current: string;
  #currentTime = 0;
  #trans: ActiveTransition | null = null;

  constructor(states: readonly AnimState[], transitions: readonly Transition[], initial: string) {
    this.#states = new Map(states.map((s) => [s.name, s]));
    if (!this.#states.has(initial)) throw new RangeError(`初始状态 ${initial} 不存在`);
    this.#transitions = transitions;
    this.#current = initial;
  }

  get currentState(): string { return this.#current; }
  get isTransitioning(): boolean { return this.#trans !== null; }

  setParam(name: string, value: ParamValue): void { this.#params.set(name, value); }

  getParam(name: string): ParamValue {
    if (this.#triggers.has(name)) return true;
    return this.#params.get(name) ?? false;
  }

  /** 触发一次性参数;它被某次转移的任一条件消费后自动复位。 */
  setTrigger(name: string): void { this.#triggers.add(name); }

  #stateOf(name: string): AnimState {
    const s = this.#states.get(name);
    if (!s) throw new RangeError(`状态 ${name} 不存在`);
    return s;
  }

  /** 当前剪辑的归一化进度 [0, 1],用于 exitTime 判断。 */
  #normalizedProgress(): number {
    const dur = this.#stateOf(this.#current).clip.duration;
    if (!(dur > 0)) return 1;
    return wrapTime(this.#currentTime, dur, this.#stateOf(this.#current).loop) / dur;
  }

  #conditionMet(c: Condition): boolean {
    const raw = this.getParam(c.param);
    const num = typeof raw === 'boolean' ? (raw ? 1 : 0) : raw;
    switch (c.op) {
      case '>': return num > c.threshold;
      case '<': return num < c.threshold;
      case '>=': return num >= c.threshold;
      case '<=': return num <= c.threshold;
      case '==': return num === c.threshold;
      case '!=': return num !== c.threshold;
    }
  }

  #findTransition(): Transition | null {
    const progress = this.#normalizedProgress();
    for (const tr of this.#transitions) {
      if (tr.from !== this.#current && tr.from !== '*') continue;
      if (tr.to === this.#current) continue;
      if (tr.exitTime !== undefined && progress < tr.exitTime) continue;
      if (tr.conditions.every((c) => this.#conditionMet(c))) return tr;
    }
    return null;
  }

  #startTransition(tr: Transition): void {
    this.#trans = { from: this.#current, to: tr.to, elapsed: 0, duration: tr.duration, toTime: 0 };
    // 消费本转移条件引用到的一次性 trigger
    for (const c of tr.conditions) this.#triggers.delete(c.param);
  }

  /** 推进 dt 秒,返回本帧的混合结果。 */
  update(dt: number): MachineOutput {
    const cur = this.#stateOf(this.#current);
    this.#currentTime += dt * cur.speed;

    if (this.#trans) {
      const to = this.#stateOf(this.#trans.to);
      this.#trans.toTime += dt * to.speed;
      this.#trans.elapsed += dt;
      const w = this.#trans.duration > 0 ? Math.min(1, this.#trans.elapsed / this.#trans.duration) : 1;
      if (w >= 1) {
        this.#current = this.#trans.to;
        this.#currentTime = this.#trans.toTime;
        this.#trans = null;
        return { fromState: this.#current, toState: this.#current, fromTime: this.#currentTime, toTime: this.#currentTime, weight: 1, transitioning: false };
      }
      return { fromState: this.#trans.from, toState: this.#trans.to, fromTime: this.#currentTime, toTime: this.#trans.toTime, weight: w, transitioning: true };
    }

    const tr = this.#findTransition();
    if (tr) {
      this.#startTransition(tr);
      return { fromState: tr.from === '*' ? this.#current : tr.from, toState: tr.to, fromTime: this.#currentTime, toTime: 0, weight: 0, transitioning: true };
    }
    return { fromState: this.#current, toState: this.#current, fromTime: this.#currentTime, toTime: this.#currentTime, weight: 1, transitioning: false };
  }
}

调用侧拿到 MachineOutput 后,用两条状态各自的剪辑在 fromTime / toTime 上求值(3.1 的 evaluateClip),再用 weight 走一次 blendPose——求值器与状态机就这样接起来了。

可运行产物:单测覆盖转移的每条路径

src/subsys/anim/state-machine.test.ts 有 10 条断言:交叉淡化取平移中点、旋转走 slerp;一维混合树在阈值之间给线性权重、越界落回端点;状态机在条件不满足时不跳、满足后权重从 0 升到 1 并提交状态、exitTime 未到之前不跳、trigger 被消费后自动复位、'*' 转移从任意状态可触发。本机实跑:

$ npx vitest run src/subsys/anim/state-machine.test.ts
 ✓ src/subsys/anim/state-machine.test.ts (10 tests)
 Test Files  1 passed (1)
      Tests  10 passed (10)

到这里动画的「上层逻辑」齐了:求值器出姿态,状态机选剪辑,混合树做过渡。但姿态目前只是浮点数组,还没贴到模型的皮上。3.3 把它接到骨骼与蒙皮上,并量化「蒙皮比静态网格贵多少」。

阅读导航:上一节:3.1 剪辑、轨道与求值器 · 下一节:3.3 蒙皮集成与性能基准 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径