@certe/atmos-animation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # 🎬 @certe/atmos-animation
2
+
3
+ Skeletal animation system for the Atmos Engine. Provides skeleton data structures, keyframe sampling, pose blending, and an `AnimationMixer` component for clip playback and cross-fading.
4
+
5
+ ---
6
+
7
+ ## 🔑 Key Concepts
8
+
9
+ - **Skeleton** — Joint hierarchy with inverse bind matrices and a rest pose
10
+ - **AnimationClip** — Named collection of keyframe tracks targeting individual joints
11
+ - **AnimationMixer** — Component that plays clips, blends weighted layers, and outputs bone matrices for GPU skinning
12
+
13
+ ---
14
+
15
+ ## 🚀 Quick Start
16
+
17
+ ```ts
18
+ import { AnimationMixer, createSkeleton, createAnimationClip } from '@certe/atmos-animation';
19
+
20
+ // Typically created automatically by instantiateModel() for glTF skinned meshes
21
+ const mixer = gameObject.addComponent(AnimationMixer);
22
+ mixer.skeleton = skeleton;
23
+ mixer.addClip(walkClip);
24
+ mixer.addClip(runClip);
25
+
26
+ // Play
27
+ const walkLayer = mixer.play(walkClip, { loop: true });
28
+
29
+ // Cross-fade to run over 0.3 seconds
30
+ const runLayer = mixer.play(runClip, { loop: true, weight: 0 });
31
+ mixer.crossFade(walkLayer, runLayer, 0.3);
32
+ ```
33
+
34
+ ---
35
+
36
+ ## 📖 API Overview
37
+
38
+ ### Skeleton
39
+
40
+ ```ts
41
+ import { createSkeleton, getInverseBindMatrix } from '@certe/atmos-animation';
42
+
43
+ const skeleton = createSkeleton(
44
+ joints, // Array<{ name, parentIndex }>
45
+ inverseBindMatrices, // Float32Array (jointCount × 16)
46
+ restT, restR, restS // Optional rest pose arrays
47
+ );
48
+
49
+ // Read one joint's IBM
50
+ const ibm = Mat4.create();
51
+ getInverseBindMatrix(ibm, skeleton, jointIndex);
52
+ ```
53
+
54
+ ### AnimationClip & Tracks
55
+
56
+ ```ts
57
+ import { createAnimationClip } from '@certe/atmos-animation';
58
+
59
+ const clip = createAnimationClip('walk', [
60
+ {
61
+ jointIndex: 0,
62
+ channel: 'rotation',
63
+ interpolation: 'LINEAR',
64
+ times: new Float32Array([0, 0.5, 1.0]),
65
+ values: new Float32Array([/* quaternion keyframes */]),
66
+ },
67
+ ]);
68
+ // clip.duration is auto-computed from max track time
69
+ ```
70
+
71
+ ### Keyframe Sampling
72
+
73
+ ```ts
74
+ import { sampleTrack } from '@certe/atmos-animation';
75
+
76
+ const out = new Float32Array(4); // 4 for rotation, 3 for translation/scale
77
+ sampleTrack(out, track, time);
78
+ // Binary search + LINEAR (lerp/slerp) or STEP interpolation
79
+ ```
80
+
81
+ ### AnimationMixer Component
82
+
83
+ | Method | Description |
84
+ |---|---|
85
+ | `play(clip, opts?)` | Play a clip, returns `AnimationLayer` |
86
+ | `playByName(name, opts?)` | Play by clip name with optional cross-fade |
87
+ | `crossFade(from, to, duration)` | Smooth transition between layers |
88
+ | `stop(layer)` | Stop and remove a layer |
89
+ | `resetToRestPose()` | Clear all layers |
90
+ | `addClip(clip)` | Register a clip for `playByName` |
91
+
92
+ | Property | Description |
93
+ |---|---|
94
+ | `skeleton` | The `Skeleton` to animate |
95
+ | `boneMatrices` | `Float32Array` output (jointCount × 16) for GPU upload |
96
+ | `clipNames` | Sorted list of registered clip names |
97
+ | `speed` | Global playback speed multiplier |
98
+ | `loop` | Default looping behavior |
99
+
100
+ ### Blending Algorithm
101
+
102
+ Each frame, `onUpdate(dt)`:
103
+
104
+ 1. Sample all active layers' tracks at their current time
105
+ 2. Blend translation/scale as **delta-from-rest** weighted by layer weight
106
+ 3. Blend rotation via **weighted quaternion accumulation** with shortest-path sign flip
107
+ 4. Fill undriven joints with rest pose
108
+ 5. Call `computeBoneMatrices()` to produce final GPU-ready matrices
109
+
110
+ ---
111
+
112
+ ## 📁 Structure
113
+
114
+ ```
115
+ packages/animation/src/
116
+ index.ts # Public API
117
+ skeleton.ts # Skeleton type + factory
118
+ animation-clip.ts # AnimationClip + KeyframeTrack
119
+ keyframe-sampler.ts # Binary search + lerp/slerp (zero-alloc)
120
+ pose.ts # computeBoneMatrices() — two-pass world × IBM
121
+ animation-mixer.ts # AnimationMixer component
122
+ animation-handler.ts # Animation event handling
123
+ register-builtins.ts # Component registry integration
124
+ ```
125
+
126
+ ---
127
+
128
+ ## 🔗 Dependencies
129
+
130
+ - `@certe/atmos-core` — Component lifecycle
131
+ - `@certe/atmos-math` — Vec3, Mat4, Quat for pose computation and interpolation
@@ -0,0 +1,25 @@
1
+ /** Interpolation mode for a keyframe track. */
2
+ export type Interpolation = 'LINEAR' | 'STEP';
3
+ /** Which transform channel a track controls. */
4
+ export type AnimationChannel = 'translation' | 'rotation' | 'scale';
5
+ /** A single track of keyframes targeting one joint's transform channel. */
6
+ export interface KeyframeTrack {
7
+ jointIndex: number;
8
+ channel: AnimationChannel;
9
+ interpolation: Interpolation;
10
+ /** Keyframe timestamps in seconds. */
11
+ times: Float32Array;
12
+ /** Keyframe values (3 floats for T/S, 4 floats for R per keyframe). */
13
+ values: Float32Array;
14
+ }
15
+ /** A named animation clip containing multiple keyframe tracks. */
16
+ export interface AnimationClip {
17
+ name: string;
18
+ duration: number;
19
+ tracks: readonly KeyframeTrack[];
20
+ }
21
+ /**
22
+ * Create an animation clip. Computes duration from the max time across all tracks.
23
+ */
24
+ export declare function createAnimationClip(name: string, tracks: KeyframeTrack[]): AnimationClip;
25
+ //# sourceMappingURL=animation-clip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"animation-clip.d.ts","sourceRoot":"","sources":["../src/animation-clip.ts"],"names":[],"mappings":"AAAA,+CAA+C;AAC/C,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9C,gDAAgD;AAChD,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,UAAU,GAAG,OAAO,CAAC;AAEpE,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,aAAa,CAAC;IAC7B,sCAAsC;IACtC,KAAK,EAAE,YAAY,CAAC;IACpB,uEAAuE;IACvE,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;CAClC;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,aAAa,EAAE,GACtB,aAAa,CASf"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Create an animation clip. Computes duration from the max time across all tracks.
3
+ */
4
+ export function createAnimationClip(name, tracks) {
5
+ let duration = 0;
6
+ for (const track of tracks) {
7
+ if (track.times.length > 0) {
8
+ const last = track.times[track.times.length - 1];
9
+ if (last > duration)
10
+ duration = last;
11
+ }
12
+ }
13
+ return { name, duration, tracks };
14
+ }
15
+ //# sourceMappingURL=animation-clip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"animation-clip.js","sourceRoot":"","sources":["../src/animation-clip.ts"],"names":[],"mappings":"AAwBA;;GAEG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAAY,EACZ,MAAuB;IAEvB,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC;YAClD,IAAI,IAAI,GAAG,QAAQ;gBAAE,QAAQ,GAAG,IAAI,CAAC;QACvC,CAAC;IACH,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;AACpC,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * AnimationHandler: lives on model root, delegates to child AnimationMixers.
3
+ * Provides a single control point for animation playback across all skinned meshes.
4
+ */
5
+ import { Component } from '@certe/atmos-core';
6
+ export declare class AnimationHandler extends Component {
7
+ /** Which clip to auto-play on start. */
8
+ initialClip: string;
9
+ /** Default playback speed multiplier. */
10
+ speed: number;
11
+ /** Whether clips loop by default. */
12
+ loop: boolean;
13
+ /** Whether to auto-play initialClip on start. */
14
+ autoplay: boolean;
15
+ /** Recursively collect all AnimationMixers from this GameObject and descendants. */
16
+ private _collectMixers;
17
+ /** Union of all clip names across child mixers, sorted and deduplicated. */
18
+ get clipNames(): string[];
19
+ /** Name of the currently playing clip (from first mixer found), or empty string. */
20
+ get currentClip(): string;
21
+ /** Play a clip by name on all child mixers. */
22
+ play(name: string, opts?: {
23
+ speed?: number;
24
+ loop?: boolean;
25
+ crossFadeDuration?: number;
26
+ }): void;
27
+ /** Stop all layers on all child mixers. */
28
+ stop(): void;
29
+ onStart(): void;
30
+ }
31
+ //# sourceMappingURL=animation-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"animation-handler.d.ts","sourceRoot":"","sources":["../src/animation-handler.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAc,MAAM,mBAAmB,CAAC;AAG1D,qBAAa,gBAAiB,SAAQ,SAAS;IAC7C,wCAAwC;IACxC,WAAW,SAAM;IACjB,yCAAyC;IACzC,KAAK,SAAK;IACV,qCAAqC;IACrC,IAAI,UAAQ;IACZ,iDAAiD;IACjD,QAAQ,UAAQ;IAEhB,oFAAoF;IACpF,OAAO,CAAC,cAAc;IAWtB,4EAA4E;IAC5E,IAAI,SAAS,IAAI,MAAM,EAAE,CAMxB;IAED,oFAAoF;IACpF,IAAI,WAAW,IAAI,MAAM,CAGxB;IAED,+CAA+C;IAC/C,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QACxB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,iBAAiB,CAAC,EAAE,MAAM,CAAC;KAC5B,GAAG,IAAI;IAYR,2CAA2C;IAC3C,IAAI,IAAI,IAAI;IAQZ,OAAO,IAAI,IAAI;CAWhB"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * AnimationHandler: lives on model root, delegates to child AnimationMixers.
3
+ * Provides a single control point for animation playback across all skinned meshes.
4
+ */
5
+ import { Component } from '@certe/atmos-core';
6
+ import { AnimationMixer } from './animation-mixer.js';
7
+ export class AnimationHandler extends Component {
8
+ /** Which clip to auto-play on start. */
9
+ initialClip = '';
10
+ /** Default playback speed multiplier. */
11
+ speed = 1;
12
+ /** Whether clips loop by default. */
13
+ loop = true;
14
+ /** Whether to auto-play initialClip on start. */
15
+ autoplay = true;
16
+ /** Recursively collect all AnimationMixers from this GameObject and descendants. */
17
+ _collectMixers() {
18
+ const result = [];
19
+ const walk = (go) => {
20
+ const mixer = go.getComponent(AnimationMixer);
21
+ if (mixer)
22
+ result.push(mixer);
23
+ for (const child of go.children)
24
+ walk(child);
25
+ };
26
+ walk(this.gameObject);
27
+ return result;
28
+ }
29
+ /** Union of all clip names across child mixers, sorted and deduplicated. */
30
+ get clipNames() {
31
+ const names = new Set();
32
+ for (const mixer of this._collectMixers()) {
33
+ for (const n of mixer.clipNames)
34
+ names.add(n);
35
+ }
36
+ return [...names].sort();
37
+ }
38
+ /** Name of the currently playing clip (from first mixer found), or empty string. */
39
+ get currentClip() {
40
+ const mixers = this._collectMixers();
41
+ return mixers.length > 0 ? mixers[0].currentClip : '';
42
+ }
43
+ /** Play a clip by name on all child mixers. */
44
+ play(name, opts) {
45
+ const speed = opts?.speed ?? this.speed;
46
+ const loop = opts?.loop ?? this.loop;
47
+ for (const mixer of this._collectMixers()) {
48
+ mixer.playByName(name, {
49
+ speed,
50
+ loop,
51
+ crossFadeDuration: opts?.crossFadeDuration,
52
+ });
53
+ }
54
+ }
55
+ /** Stop all layers on all child mixers. */
56
+ stop() {
57
+ for (const mixer of this._collectMixers()) {
58
+ for (const layer of [...mixer.layers]) {
59
+ mixer.stop(layer);
60
+ }
61
+ }
62
+ }
63
+ onStart() {
64
+ // Disable autoplay on all child mixers so they don't play independently
65
+ for (const mixer of this._collectMixers()) {
66
+ mixer.autoplay = false;
67
+ }
68
+ // Stop any existing playback (e.g. from previous play cycle) before starting
69
+ this.stop();
70
+ if (this.autoplay && this.initialClip) {
71
+ this.play(this.initialClip, { speed: this.speed, loop: this.loop });
72
+ }
73
+ }
74
+ }
75
+ //# sourceMappingURL=animation-handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"animation-handler.js","sourceRoot":"","sources":["../src/animation-handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * AnimationMixer component: manages animation playback, blending, and crossfade.
3
+ * Produces bone matrices each frame for GPU skinning.
4
+ */
5
+ import { Component } from '@certe/atmos-core';
6
+ import type { Skeleton } from './skeleton.js';
7
+ import type { AnimationClip } from './animation-clip.js';
8
+ /** A single active animation layer. */
9
+ export interface AnimationLayer {
10
+ clip: AnimationClip;
11
+ time: number;
12
+ speed: number;
13
+ weight: number;
14
+ loop: boolean;
15
+ playing: boolean;
16
+ /** Internal: used for crossfade weight ramping. */
17
+ _fadeSpeed: number;
18
+ /** Internal: target weight for crossfade. */
19
+ _fadeTarget: number;
20
+ }
21
+ export declare class AnimationMixer extends Component {
22
+ /** Final bone matrices (jointCount * 16 floats), uploaded to GPU each frame. */
23
+ boneMatrices: Float32Array | null;
24
+ /** Which clip to auto-play on start. */
25
+ initialClip: string;
26
+ /** Default playback speed multiplier. */
27
+ speed: number;
28
+ /** Whether clips loop by default. */
29
+ loop: boolean;
30
+ /** Whether to auto-play initialClip on start. */
31
+ autoplay: boolean;
32
+ private _skeleton;
33
+ private _layers;
34
+ private _clips;
35
+ private _blendedT;
36
+ private _blendedR;
37
+ private _blendedS;
38
+ private _accumWeightR;
39
+ get skeleton(): Skeleton | null;
40
+ set skeleton(sk: Skeleton | null);
41
+ /** Reset bone matrices to rest pose and stop all layers. */
42
+ resetToRestPose(): void;
43
+ /** Play a clip, returning the layer handle. */
44
+ play(clip: AnimationClip, opts?: {
45
+ speed?: number;
46
+ weight?: number;
47
+ loop?: boolean;
48
+ }): AnimationLayer;
49
+ /**
50
+ * Crossfade from one layer to another over `duration` seconds.
51
+ * `from` fades to 0 weight, `to` fades to 1 weight.
52
+ */
53
+ crossFade(from: AnimationLayer, to: AnimationLayer, duration: number): void;
54
+ /** Stop and remove a layer. */
55
+ stop(layer: AnimationLayer): void;
56
+ /** Get all active layers (read-only). */
57
+ get layers(): readonly AnimationLayer[];
58
+ /** Register a clip by name. */
59
+ addClip(clip: AnimationClip): void;
60
+ /** Sorted list of registered clip names. */
61
+ get clipNames(): string[];
62
+ /** Name of the currently playing clip (first layer), or empty string. */
63
+ get currentClip(): string;
64
+ /** Play a clip by name, with optional crossfade from the current clip. */
65
+ playByName(name: string, opts?: {
66
+ speed?: number;
67
+ loop?: boolean;
68
+ crossFadeDuration?: number;
69
+ }): AnimationLayer | null;
70
+ onStart(): void;
71
+ onUpdate(dt: number): void;
72
+ private _advanceLayers;
73
+ private _accumulateLayer;
74
+ }
75
+ //# sourceMappingURL=animation-mixer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"animation-mixer.d.ts","sourceRoot":"","sources":["../src/animation-mixer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE9C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD,uCAAuC;AACvC,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,mDAAmD;IACnD,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAC;CACrB;AAQD,qBAAa,cAAe,SAAQ,SAAS;IAC3C,gFAAgF;IAChF,YAAY,EAAE,YAAY,GAAG,IAAI,CAAQ;IAEzC,wCAAwC;IACxC,WAAW,SAAM;IACjB,yCAAyC;IACzC,KAAK,SAAK;IACV,qCAAqC;IACrC,IAAI,UAAQ;IACZ,iDAAiD;IACjD,QAAQ,UAAQ;IAEhB,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAoC;IAGlD,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,SAAS,CAA6B;IAG9C,OAAO,CAAC,aAAa,CAA6B;IAElD,IAAI,QAAQ,IAAI,QAAQ,GAAG,IAAI,CAA2B;IAE1D,IAAI,QAAQ,CAAC,EAAE,EAAE,QAAQ,GAAG,IAAI,EAmB/B;IAED,4DAA4D;IAC5D,eAAe,IAAI,IAAI;IAUvB,+CAA+C;IAC/C,IAAI,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE;QAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,OAAO,CAAC;KAChB,GAAG,cAAc;IAelB;;;OAGG;IACH,SAAS,CAAC,IAAI,EAAE,cAAc,EAAE,EAAE,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAS3E,+BAA+B;IAC/B,IAAI,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAMjC,yCAAyC;IACzC,IAAI,MAAM,IAAI,SAAS,cAAc,EAAE,CAEtC;IAED,+BAA+B;IAC/B,OAAO,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI;IAIlC,4CAA4C;IAC5C,IAAI,SAAS,IAAI,MAAM,EAAE,CAExB;IAED,yEAAyE;IACzE,IAAI,WAAW,IAAI,MAAM,CAGxB;IAED,0EAA0E;IAC1E,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,iBAAiB,CAAC,EAAE,MAAM,CAAC;KAC5B,GAAG,cAAc,GAAG,IAAI;IAczB,OAAO,IAAI,IAAI;IAMf,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAgE1B,OAAO,CAAC,cAAc;IAkCtB,OAAO,CAAC,gBAAgB;CAqDzB"}
@@ -0,0 +1,274 @@
1
+ /**
2
+ * AnimationMixer component: manages animation playback, blending, and crossfade.
3
+ * Produces bone matrices each frame for GPU skinning.
4
+ */
5
+ import { Component } from '@certe/atmos-core';
6
+ import { Quat } from '@certe/atmos-math';
7
+ import { sampleTrack } from './keyframe-sampler.js';
8
+ import { computeBoneMatrices } from './pose.js';
9
+ // Scratch arrays for blending (module-level, zero alloc)
10
+ const _sampledT = new Float32Array(3);
11
+ const _sampledR = new Float32Array(4);
12
+ const _sampledS = new Float32Array(3);
13
+ const _blendR = new Float32Array(4);
14
+ export class AnimationMixer extends Component {
15
+ /** Final bone matrices (jointCount * 16 floats), uploaded to GPU each frame. */
16
+ boneMatrices = null;
17
+ /** Which clip to auto-play on start. */
18
+ initialClip = '';
19
+ /** Default playback speed multiplier. */
20
+ speed = 1;
21
+ /** Whether clips loop by default. */
22
+ loop = true;
23
+ /** Whether to auto-play initialClip on start. */
24
+ autoplay = true;
25
+ _skeleton = null;
26
+ _layers = [];
27
+ _clips = new Map();
28
+ // Per-joint blended T/R/S (allocated when skeleton is set)
29
+ _blendedT = null;
30
+ _blendedR = null;
31
+ _blendedS = null;
32
+ // Accumulated rotation weight per joint (for rest-pose fill after blending)
33
+ _accumWeightR = null;
34
+ get skeleton() { return this._skeleton; }
35
+ set skeleton(sk) {
36
+ this._skeleton = sk;
37
+ if (sk) {
38
+ const jc = sk.jointCount;
39
+ this._blendedT = new Float32Array(jc * 3);
40
+ this._blendedR = new Float32Array(jc * 4);
41
+ this._blendedS = new Float32Array(jc * 3);
42
+ this.boneMatrices = new Float32Array(jc * 16);
43
+ // Compute rest-pose bone matrices immediately so GPU has valid data before first update
44
+ this._blendedT.set(sk.restT);
45
+ this._blendedR.set(sk.restR);
46
+ this._blendedS.set(sk.restS);
47
+ computeBoneMatrices(this.boneMatrices, sk, this._blendedT, this._blendedR, this._blendedS);
48
+ }
49
+ else {
50
+ this.boneMatrices = null;
51
+ this._blendedT = null;
52
+ this._blendedR = null;
53
+ this._blendedS = null;
54
+ }
55
+ }
56
+ /** Reset bone matrices to rest pose and stop all layers. */
57
+ resetToRestPose() {
58
+ const sk = this._skeleton;
59
+ if (!sk || !this.boneMatrices || !this._blendedT)
60
+ return;
61
+ this._layers.length = 0;
62
+ this._blendedT.set(sk.restT);
63
+ this._blendedR.set(sk.restR);
64
+ this._blendedS.set(sk.restS);
65
+ computeBoneMatrices(this.boneMatrices, sk, this._blendedT, this._blendedR, this._blendedS);
66
+ }
67
+ /** Play a clip, returning the layer handle. */
68
+ play(clip, opts) {
69
+ const layer = {
70
+ clip,
71
+ time: 0,
72
+ speed: opts?.speed ?? 1,
73
+ weight: opts?.weight ?? 1,
74
+ loop: opts?.loop ?? true,
75
+ playing: true,
76
+ _fadeSpeed: 0,
77
+ _fadeTarget: opts?.weight ?? 1,
78
+ };
79
+ this._layers.push(layer);
80
+ return layer;
81
+ }
82
+ /**
83
+ * Crossfade from one layer to another over `duration` seconds.
84
+ * `from` fades to 0 weight, `to` fades to 1 weight.
85
+ */
86
+ crossFade(from, to, duration) {
87
+ const d = Math.max(duration, 0.001);
88
+ from._fadeTarget = 0;
89
+ from._fadeSpeed = from.weight / d;
90
+ to.weight = 0;
91
+ to._fadeTarget = 1;
92
+ to._fadeSpeed = 1 / d;
93
+ }
94
+ /** Stop and remove a layer. */
95
+ stop(layer) {
96
+ layer.playing = false;
97
+ const idx = this._layers.indexOf(layer);
98
+ if (idx >= 0)
99
+ this._layers.splice(idx, 1);
100
+ }
101
+ /** Get all active layers (read-only). */
102
+ get layers() {
103
+ return this._layers;
104
+ }
105
+ /** Register a clip by name. */
106
+ addClip(clip) {
107
+ this._clips.set(clip.name, clip);
108
+ }
109
+ /** Sorted list of registered clip names. */
110
+ get clipNames() {
111
+ return [...this._clips.keys()].sort();
112
+ }
113
+ /** Name of the currently playing clip (first layer), or empty string. */
114
+ get currentClip() {
115
+ const first = this._layers[0];
116
+ return first ? first.clip.name : '';
117
+ }
118
+ /** Play a clip by name, with optional crossfade from the current clip. */
119
+ playByName(name, opts) {
120
+ const clip = this._clips.get(name);
121
+ if (!clip)
122
+ return null;
123
+ const layer = this.play(clip, {
124
+ speed: opts?.speed ?? this.speed,
125
+ loop: opts?.loop ?? this.loop,
126
+ });
127
+ if (opts?.crossFadeDuration && this._layers.length > 1) {
128
+ const from = this._layers[this._layers.length - 2];
129
+ this.crossFade(from, layer, opts.crossFadeDuration);
130
+ }
131
+ return layer;
132
+ }
133
+ onStart() {
134
+ if (this.autoplay && this.initialClip) {
135
+ this.playByName(this.initialClip, { speed: this.speed, loop: this.loop });
136
+ }
137
+ }
138
+ onUpdate(dt) {
139
+ if (!this._skeleton || !this._blendedT)
140
+ return;
141
+ const jc = this._skeleton.jointCount;
142
+ // Advance layers and handle fades
143
+ this._advanceLayers(dt);
144
+ // Start from rest pose (T/S stay as rest, R zeroed for accumulation)
145
+ const sk = this._skeleton;
146
+ this._blendedT.set(sk.restT);
147
+ this._blendedS.set(sk.restS);
148
+ this._blendedR.fill(0);
149
+ // Track accumulated rotation weight per joint
150
+ if (!this._accumWeightR || this._accumWeightR.length !== jc) {
151
+ this._accumWeightR = new Float32Array(jc);
152
+ }
153
+ this._accumWeightR.fill(0);
154
+ // Accumulate weighted samples
155
+ for (const layer of this._layers) {
156
+ if (!layer.playing || layer.weight <= 0)
157
+ continue;
158
+ this._accumulateLayer(layer, jc);
159
+ }
160
+ // Fill in rest-pose quaternion for joints not fully covered by animations
161
+ const br = this._blendedR;
162
+ for (let j = 0; j < jc; j++) {
163
+ const aw = this._accumWeightR[j];
164
+ const rOff = j * 4;
165
+ if (aw < 0.001) {
166
+ // No animation touched this joint — use rest pose directly
167
+ br[rOff] = sk.restR[rOff];
168
+ br[rOff + 1] = sk.restR[rOff + 1];
169
+ br[rOff + 2] = sk.restR[rOff + 2];
170
+ br[rOff + 3] = sk.restR[rOff + 3];
171
+ }
172
+ else if (aw < 0.999) {
173
+ // Partially covered — blend in rest pose for remaining weight
174
+ const restW = 1 - aw;
175
+ const dot = br[rOff] * sk.restR[rOff] + br[rOff + 1] * sk.restR[rOff + 1] +
176
+ br[rOff + 2] * sk.restR[rOff + 2] + br[rOff + 3] * sk.restR[rOff + 3];
177
+ const sign = dot < 0 ? -1 : 1;
178
+ br[rOff] = br[rOff] + sk.restR[rOff] * restW * sign;
179
+ br[rOff + 1] = br[rOff + 1] + sk.restR[rOff + 1] * restW * sign;
180
+ br[rOff + 2] = br[rOff + 2] + sk.restR[rOff + 2] * restW * sign;
181
+ br[rOff + 3] = br[rOff + 3] + sk.restR[rOff + 3] * restW * sign;
182
+ }
183
+ Quat.normalize(br.subarray(rOff, rOff + 4), br.subarray(rOff, rOff + 4));
184
+ }
185
+ // Compute final bone matrices
186
+ computeBoneMatrices(this.boneMatrices, this._skeleton, this._blendedT, this._blendedR, this._blendedS);
187
+ }
188
+ _advanceLayers(dt) {
189
+ for (let i = this._layers.length - 1; i >= 0; i--) {
190
+ const layer = this._layers[i];
191
+ if (!layer.playing)
192
+ continue;
193
+ // Advance time
194
+ layer.time += dt * layer.speed;
195
+ // Loop or clamp
196
+ if (layer.loop) {
197
+ if (layer.clip.duration > 0) {
198
+ layer.time = layer.time % layer.clip.duration;
199
+ if (layer.time < 0)
200
+ layer.time += layer.clip.duration;
201
+ }
202
+ }
203
+ else {
204
+ layer.time = Math.min(layer.time, layer.clip.duration);
205
+ }
206
+ // Apply fade
207
+ if (layer._fadeSpeed > 0) {
208
+ if (layer.weight < layer._fadeTarget) {
209
+ layer.weight = Math.min(layer.weight + layer._fadeSpeed * dt, layer._fadeTarget);
210
+ }
211
+ else if (layer.weight > layer._fadeTarget) {
212
+ layer.weight = Math.max(layer.weight - layer._fadeSpeed * dt, layer._fadeTarget);
213
+ }
214
+ // Remove fully faded-out layers
215
+ if (layer._fadeTarget === 0 && layer.weight <= 0) {
216
+ this._layers.splice(i, 1);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ _accumulateLayer(layer, jointCount) {
222
+ const w = layer.weight;
223
+ const bt = this._blendedT;
224
+ const br = this._blendedR;
225
+ const bs = this._blendedS;
226
+ const sk = this._skeleton;
227
+ // Sample each track and blend using delta-from-rest for T/S,
228
+ // weighted accumulation + rest fill for R (done in onUpdate post-pass).
229
+ for (const track of layer.clip.tracks) {
230
+ const ji = track.jointIndex;
231
+ if (ji < 0 || ji >= jointCount)
232
+ continue;
233
+ switch (track.channel) {
234
+ case 'translation': {
235
+ sampleTrack(_sampledT, track, layer.time);
236
+ const off = ji * 3;
237
+ // Delta from rest: bt starts at restT, add (sampled - rest) * weight
238
+ bt[off] = bt[off] + (_sampledT[0] - sk.restT[off]) * w;
239
+ bt[off + 1] = bt[off + 1] + (_sampledT[1] - sk.restT[off + 1]) * w;
240
+ bt[off + 2] = bt[off + 2] + (_sampledT[2] - sk.restT[off + 2]) * w;
241
+ break;
242
+ }
243
+ case 'rotation': {
244
+ sampleTrack(_sampledR, track, layer.time);
245
+ const rOff = ji * 4;
246
+ this._accumWeightR[ji] = this._accumWeightR[ji] + w;
247
+ // Weighted quaternion accumulation (shortest path)
248
+ _blendR[0] = br[rOff];
249
+ _blendR[1] = br[rOff + 1];
250
+ _blendR[2] = br[rOff + 2];
251
+ _blendR[3] = br[rOff + 3];
252
+ const dot = _blendR[0] * _sampledR[0] + _blendR[1] * _sampledR[1] +
253
+ _blendR[2] * _sampledR[2] + _blendR[3] * _sampledR[3];
254
+ const sign = dot < 0 ? -1 : 1;
255
+ br[rOff] = br[rOff] + _sampledR[0] * w * sign;
256
+ br[rOff + 1] = br[rOff + 1] + _sampledR[1] * w * sign;
257
+ br[rOff + 2] = br[rOff + 2] + _sampledR[2] * w * sign;
258
+ br[rOff + 3] = br[rOff + 3] + _sampledR[3] * w * sign;
259
+ break;
260
+ }
261
+ case 'scale': {
262
+ sampleTrack(_sampledS, track, layer.time);
263
+ const sOff = ji * 3;
264
+ // Delta from rest: bs starts at restS, add (sampled - rest) * weight
265
+ bs[sOff] = bs[sOff] + (_sampledS[0] - sk.restS[sOff]) * w;
266
+ bs[sOff + 1] = bs[sOff + 1] + (_sampledS[1] - sk.restS[sOff + 1]) * w;
267
+ bs[sOff + 2] = bs[sOff + 2] + (_sampledS[2] - sk.restS[sOff + 2]) * w;
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
274
+ //# sourceMappingURL=animation-mixer.js.map