3.1 剪辑、轨道与求值器
前两章把资源搬上了船:导入器产出资产,流式把大关卡切块加载。这一章开始让《微光岛》动起来——从最底层的一段动画剪辑讲起。所谓动画,拆开看只有三层:剪辑是一段时间轴,轨道把某条时间轴绑到某个节点的一条通道(平移/旋转/缩放)上,关键帧是时间轴上的采样点。求值器的工作,就是在任意时刻 t 把这三层塌缩成一份具体的姿态。
这一节先把数据模型定死,再实现求值器,最后用单测把环绕语义与三种插值钉死。3.2 在这一层之上叠状态机与混合树,3.3 把姿态接到骨骼上做蒙皮。
本章与站内既有内容的分工
本站已有内容覆盖了动画的「原理」与「子系统视角」,本卷只做「从零实现 + 每节跑出真实产物」:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-skeletal-animation/ | 蒙皮原理:LBS / 双四元数、权重归一、逐顶点变换 | 接入侧的剪辑 / 轨道 / 求值器(本节不碰蒙皮) |
/posts/defold/ | 引擎子系统视角:animation 组件、脚本驱动、精灵动画 | 从零实现关键帧求值器并跑通单测 |
/graphics-gltf-asset-pipeline/ | glTF 动画数据的规范与解析 | 定义与之一一对应的 Track / Clip 结构 |
一句话:原理与规范去站内既有文章,本节只交付一份能跑的 src/subsys/anim/ 代码。
剪辑、轨道与关键帧
数据模型刻意对齐 glTF 的 animation:一条 Track 对应一个 channel + sampler,times 单调不减,values 按通道连续排列。通道分量数是三(平移/缩放)或四(旋转四元数),而 cubic 插值下每个关键帧要额外带一对切线,所以每帧在 values 里占的 float 数是 stride——step/linear 为 components,cubic 为 3 * components。这套布局容易在构造时写错,于是把长度自洽性放进 makeTrack 的校验里。
// file: src/subsys/anim/clip.ts
// tier: L1
// verify: npx vitest run src/subsys/anim/evaluator.test.ts
//
// helio 子系统卷 3.1:动画剪辑的数据模型。一个 Clip 是「若干条轨道 + 时长」,
// 一条 Track 绑定到某个节点的一条通道(平移 / 旋转 / 缩放),持有一串关键帧。
// 这里的字段布局刻意对齐 glTF 的 animation(tracks 对应 channel+sampler),
// 以便 3.3 直接吃导入器产出的骨骼动画。
/** 通道取值元组:平移/缩放 3 个数,旋转 4 个数(四元数 x, y, z, w)。 */
export type Vec3 = readonly [number, number, number];
export type Quat = readonly [number, number, number, number];
/** 关键帧之间的插值方式,对应 glTF 的 sampler.interpolation。 */
export type Interpolation = 'step' | 'linear' | 'cubic';
/** 一条轨道作用的通道。 */
export type Channel = 'translation' | 'rotation' | 'scale';
/** 播放时间轴越过剪辑末尾后的环绕方式。 */
export type LoopMode = 'clamp' | 'loop' | 'pingpong';
/**
* 一条动画轨道。times 单调不减(秒,相对剪辑起点)。
* 关键帧数值按通道连续排列:
* - step / linear:每帧占 components 个 float;
* - cubic:每帧占 3 * components 个 float,依次是 [入切线, 数值, 出切线](对齐 glTF CUBICSPLINE)。
*/
export interface Track {
readonly target: number;
readonly channel: Channel;
readonly interpolation: Interpolation;
readonly times: Float32Array;
readonly values: Float32Array;
}
/** 一段动画剪辑。 */
export interface Clip {
readonly name: string;
readonly duration: number;
readonly tracks: readonly Track[];
}
/** 每个通道每帧占几个分量。 */
export function componentsOf(channel: Channel): number {
return channel === 'rotation' ? 4 : 3;
}
/** 该插值方式下,每个关键帧在 values 里占几个 float。 */
export function strideOf(track: Pick<Track, 'channel' | 'interpolation'>): number {
const c = componentsOf(track.channel);
return track.interpolation === 'cubic' ? 3 * c : c;
}
/** 构造一条轨道,并在构造时校验 times / values 的长度自洽。 */
export function makeTrack(
target: number,
channel: Channel,
interpolation: Interpolation,
times: readonly number[],
values: readonly number[],
): Track {
const t = Float32Array.from(times);
const v = Float32Array.from(values);
const stride = strideOf({ channel, interpolation });
if (t.length === 0) throw new RangeError('轨道至少要有一个关键帧');
if (v.length !== t.length * stride) {
throw new RangeError(
`values 长度 ${v.length} 与 times ${t.length} × stride ${stride} 不符`,
);
}
for (let i = 1; i < t.length; i++) {
if (t[i]! < t[i - 1]!) throw new RangeError(`关键帧时间必须单调不减,下标 ${i}`);
}
return { target, channel, interpolation, times: t, values: v };
}
从零实现:时间轴、二分与插值
求值器只有三件事。第一件是时间轴环绕:clamp 把越界时间夹到端点,loop 取模,pingpong 在后半周期镜像折返——这是「一段动画播完之后怎么办」的全部答案。第二件是定位关键帧:times 单调,于是二分找出 t 落入的区间左端点,O(log n) 而不是线性扫。第三件是插值:平移与缩放逐分量线性插值,旋转必须走 slerp(最短弧),cubic 则按 glTF 的 Hermite 基求值,切线按秒给出故要乘区间长度。
一个容易忽略的细节:n === 1(只有一帧)和 t 落在首帧之前/末帧之后这三种退化情形,都必须单独处理,否则二分会在长度不足时越界。
// file: src/subsys/anim/evaluator.ts
// tier: L1
// verify: npx vitest run src/subsys/anim/evaluator.test.ts
//
// helio 子系统卷 3.1:剪辑求值器。给定剪辑与时间,二分定位相邻关键帧,按插值方式
// 采样。时间轴支持钳制 / 循环 / 乒乓三种环绕方式——这是「一段动画播完之后怎么办」
// 的全部答案。结果由 pose.ts 写进姿态缓冲。
import { componentsOf, strideOf, type LoopMode, type Track } from './clip.ts';
export type { LoopMode };
/** 时间轴环绕:把任意时间映射回 [0, duration]。 */
export function wrapTime(time: number, duration: number, mode: LoopMode): number {
if (!(duration > 0)) return 0;
if (mode === 'clamp') return Math.min(Math.max(time, 0), duration);
if (mode === 'loop') {
const m = time % duration;
return m < 0 ? m + duration : m;
}
// pingpong:周期 2 * duration,后半段镜像折返
const period = 2 * duration;
let m = time % period;
if (m < 0) m += period;
return m <= duration ? m : period - m;
}
/**
* 二分找到最大的 i 使 times[i] <= t(即 t 落入的区间左端点)。
* t 早于首帧时返回 -1;t 不早于末帧时返回 times.length - 1。
*/
export function findKeyIndex(times: Float32Array, t: number): number {
const n = times.length;
if (n === 0) return -1;
if (t < times[0]!) return -1;
if (t >= times[n - 1]!) return n - 1;
let lo = 0;
let hi = n - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (times[mid]! <= t) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** 取第 i 个关键帧、第 k 个分量的数值(跳过 cubic 的切线)。 */
function keyValue(track: Track, i: number, k: number): number {
const c = componentsOf(track.channel);
const stride = strideOf(track);
const base = track.interpolation === 'cubic' ? i * stride + c : i * stride;
return track.values[base + k]!;
}
/** 取第 i 个关键帧、第 k 个分量的切线(入切线在下标 k,出切线在下标 2c + k)。 */
function keyTangent(track: Track, i: number, k: number, out: boolean): number {
const c = componentsOf(track.channel);
const stride = strideOf(track);
return track.values[i * stride + (out ? 2 * c : 0) + k]!;
}
/** 单位四元数的球面插值,最短弧。分量按 (x, y, z, w) 连续存放。 */
export function slerpInto(
out: Float32Array, o: number,
ax: number, ay: number, az: number, aw: number,
bx: number, by: number, bz: number, bw: number,
t: number,
): void {
let cos = ax * bx + ay * by + az * bz + aw * bw;
let tx = bx;
let ty = by;
let tz = bz;
let tw = bw;
if (cos < 0) { cos = -cos; tx = -tx; ty = -ty; tz = -tz; tw = -tw; }
if (cos > 0.9995) {
// 近似平行:线性插值再归一化,避免 sin(theta) 趋零时的除零
const x = ax + (tx - ax) * t;
const y = ay + (ty - ay) * t;
const z = az + (tz - az) * t;
const w = aw + (tw - aw) * t;
const inv = 1 / (Math.hypot(x, y, z, w) || 1);
out[o] = x * inv; out[o + 1] = y * inv; out[o + 2] = z * inv; out[o + 3] = w * inv;
return;
}
const theta = Math.acos(cos);
const sinTheta = Math.sin(theta);
const wa = Math.sin((1 - t) * theta) / sinTheta;
const wb = Math.sin(t * theta) / sinTheta;
out[o] = ax * wa + tx * wb;
out[o + 1] = ay * wa + ty * wb;
out[o + 2] = az * wa + tz * wb;
out[o + 3] = aw * wa + tw * wb;
}
/**
* 在时间 t 采样一条轨道,结果写进 out 的 [offset, offset + components) 区间。
* t 早于首帧取首帧,晚于末帧取末帧(环绕由调用方先用 wrapTime 处理)。
*/
export function sampleTrackInto(track: Track, t: number, out: Float32Array, offset = 0): void {
const c = componentsOf(track.channel);
const n = track.times.length;
if (n === 0) throw new RangeError('轨道至少要有一个关键帧');
if (n === 1) {
for (let k = 0; k < c; k++) out[offset + k] = keyValue(track, 0, k);
return;
}
const i = findKeyIndex(track.times, t);
if (i < 0) { for (let k = 0; k < c; k++) out[offset + k] = keyValue(track, 0, k); return; }
if (i >= n - 1) { for (let k = 0; k < c; k++) out[offset + k] = keyValue(track, n - 1, k); return; }
if (track.interpolation === 'step') {
for (let k = 0; k < c; k++) out[offset + k] = keyValue(track, i, k);
return;
}
const t0 = track.times[i]!;
const t1 = track.times[i + 1]!;
const span = t1 - t0;
const u = span > 0 ? (t - t0) / span : 0;
if (track.interpolation === 'cubic') {
// glTF CUBICSPLINE 的 Hermite 基:切线按秒给出,故先乘区间长度 span。
const u2 = u * u;
const u3 = u2 * u;
const h00 = 2 * u3 - 3 * u2 + 1;
const h10 = u3 - 2 * u2 + u;
const h01 = -2 * u3 + 3 * u2;
const h11 = u3 - u2;
for (let k = 0; k < c; k++) {
const p0 = keyValue(track, i, k);
const p1 = keyValue(track, i + 1, k);
const m0 = keyTangent(track, i, k, true) * span;
const m1 = keyTangent(track, i + 1, k, false) * span;
out[offset + k] = h00 * p0 + h10 * m0 + h01 * p1 + h11 * m1;
}
if (track.channel === 'rotation') {
const inv = 1 / (Math.hypot(out[offset]!, out[offset + 1]!, out[offset + 2]!, out[offset + 3]!) || 1);
for (let k = 0; k < 4; k++) out[offset + k] = out[offset + k]! * inv;
}
return;
}
// linear:平移/缩放逐分量 lerp,旋转 slerp
if (track.channel === 'rotation') {
slerpInto(
out, offset,
keyValue(track, i, 0), keyValue(track, i, 1), keyValue(track, i, 2), keyValue(track, i, 3),
keyValue(track, i + 1, 0), keyValue(track, i + 1, 1), keyValue(track, i + 1, 2), keyValue(track, i + 1, 3),
u,
);
return;
}
for (let k = 0; k < c; k++) {
const a = keyValue(track, i, k);
const b = keyValue(track, i + 1, k);
out[offset + k] = a + (b - a) * u;
}
}
采样结果写进 pose.ts 的姿态缓冲:每个节点一份平移/旋转/缩放,touched 数组记录本帧被哪条轨道写过,好让上层只重算被动画的节点。evaluateClip 返回被写过的节点下标列表,这就是「部分骨骼更新」的接口。
可运行产物:单测把环绕与插值钉死
src/subsys/anim/evaluator.test.ts 有 14 条断言,覆盖三种环绕(含负数时间)、二分的边界(早于首帧、恰在末帧)、三种插值(线性中点、90° 旋转的 slerp 中点应为 45°、cubic 的切线把中点推离线性值),以及 evaluateClip 只报告被驱动的节点。本机实跑:
$ npx vitest run src/subsys/anim/evaluator.test.ts
✓ src/subsys/anim/evaluator.test.ts (14 tests)
Test Files 1 passed (1)
Tests 14 passed (14)
下一步在这一层之上加「状态」与「混合」:单段剪辑只能循环,玩家按 W 键要从 idle 切到 run,受伤要打断当前动作——那是 3.2 的事。
阅读导航:上一节:回到目录 · 下一节:3.2 状态机与混合树 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。