reze-engine 0.8.1 → 0.8.2

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 CHANGED
@@ -2,110 +2,32 @@
2
2
 
3
3
  A lightweight engine built with WebGPU and TypeScript for real-time 3D anime character MMD model rendering.
4
4
 
5
- ![screenshot](./screenshot.png)
6
-
7
5
  ## Features
8
6
 
9
- - Blinn-Phong lighting
10
- - Alpha blending
11
- - Post alpha eye rendering (the see-through eyes)
12
- - Rim lighting
13
- - Outlines
14
- - MSAA 4x anti-aliasing
15
- - Bone and morph API
16
- - VMD animation
17
- - IK solver
18
- - Ammo/Bullet physics
7
+ - Blinn-Phong lighting, alpha blending, rim lighting, outlines, MSAA 4x
8
+ - Post alpha eye rendering (see-through eyes)
9
+ - Bone and morph API, VMD animation (multiple named, non-interruptible), IK solver, Ammo/Bullet physics
10
+ - Multi-model (per-model materials, IK, physics)
19
11
 
20
12
  ## Usage
21
13
 
22
14
  ```javascript
23
15
  import { Engine, Model } from "reze-engine"
24
16
 
25
- export default function Scene() {
26
- const canvasRef = useRef < HTMLCanvasElement > null
27
- const engineRef = useRef < Engine > null
28
-
29
- const initEngine = useCallback(async () => {
30
- if (canvasRef.current) {
31
- try {
32
- const engine = new Engine(canvasRef.current, {})
33
- engineRef.current = engine
34
- await engine.init()
35
- await Model.loadPmx("/models/reze/reze.pmx")
36
-
37
- engine.runRenderLoop(() => {})
38
- } catch (error) {
39
- console.error(error)
40
- }
41
- }
42
- }, [])
43
-
44
- useEffect(() => {
45
- void (async () => {
46
- initEngine()
47
- })()
48
-
49
- return () => {
50
- if (engineRef.current) {
51
- engineRef.current.dispose()
52
- }
53
- }
54
- }, [initEngine])
55
-
56
- return <canvas ref={canvasRef} className="w-full h-full" />
57
- }
58
- ```
59
-
60
- Engine options
61
-
62
- ```javascript
63
- const DEFAULT_ENGINE_OPTIONS: RequiredEngineOptions = {
64
- ambientColor: new Vec3(0.82, 0.82, 0.82),
65
- directionalLightIntensity: 0.2,
66
- minSpecularIntensity: 0.3,
67
- rimLightIntensity: 0.4,
68
- cameraDistance: 26.6,
69
- cameraTarget: new Vec3(0, 12.5, 0),
70
- cameraFov: Math.PI / 4,
71
- onRaycast: undefined,
72
- }
73
- ```
74
-
75
- ## API
76
-
77
- Use **`Model.loadPmx(path)`** after **`await engine.init()`**—the model is registered on the engine automatically.
78
-
79
- ### Animation (model instance)
80
-
81
- ```javascript
82
- const model = await Model.loadPmx("/models/char.pmx")
17
+ const engine = new Engine(canvas, {})
18
+ await engine.init()
19
+ const model = await Model.loadPmx("/models/reze/reze.pmx")
83
20
  await model.loadVmd("/animations/dance.vmd")
84
21
  model.playAnimation()
85
- model.pauseAnimation()
86
- model.stopAnimation()
87
- model.seekAnimation(2.5)
88
-
89
- const { current, duration, percentage } = model.getAnimationProgress()
90
- ```
91
-
92
- ### Bones / morphs (model)
93
-
94
- ```javascript
95
- model.rotateBones({ 首: neckQuat, 頭: headQuat }, 300)
96
- model.moveBones({ センター: centerVec }, 300)
97
- model.setMorphWeight("まばたき", 1.0, 300)
98
- model.resetAllBones()
99
- model.resetAllMorphs()
22
+ engine.runRenderLoop(() => {})
100
23
  ```
101
24
 
102
- ### Engine
25
+ ## API (summary)
103
26
 
104
- ```javascript
105
- engine.runRenderLoop(() => {})
106
- engine.setMaterialVisible("材質1", false)
107
- engine.addGround({ mode: "shadow", shadowMapSize: 1024 })
108
- ```
27
+ - **Multi-model:** `engine.addModel(model, pmxPath, name?)`, `getModel(name)`, `getModelNames()`, `removeModel(name)`, `setMaterialVisible(modelName, materialName, visible)`, `setModelIKEnabled(modelName, enabled)`, `setModelPhysicsEnabled(modelName, enabled)`, `resetPhysics()`, `markVertexBufferDirty(modelName?)`
28
+ - **Animation:** `model.loadVmd(url)` (loads "default", no auto-play); `model.loadAnimation(name, vmdUrl)`; `model.show(name)`; `model.play()` / `model.play(name)`; `model.pause()`; `model.stop()`; `model.seek(t)`; `model.getAnimationProgress()`. Animations are non-interruptible (next is queued).
29
+ - **Bones / morphs:** `model.rotateBones()`, `model.moveBones()`, `model.setMorphWeight()`, `model.resetAllBones()`, `model.resetAllMorphs()`
30
+ - **Engine:** `runRenderLoop()`, `addGround({ mode: "reflection" | "shadow", ... })`, `onRaycast: (modelName, material, screenX, screenY) => {}`
109
31
 
110
32
  ## Projects Using This Engine
111
33
 
@@ -1,3 +1,4 @@
1
+ import { Quat, Vec3 } from "./math";
1
2
  export interface ControlPoint {
2
3
  x: number;
3
4
  y: number;
@@ -8,6 +9,79 @@ export interface BoneInterpolation {
8
9
  translationY: ControlPoint[];
9
10
  translationZ: ControlPoint[];
10
11
  }
12
+ export interface BoneKeyframe {
13
+ boneName: string;
14
+ frame: number;
15
+ rotation: Quat;
16
+ translation: Vec3;
17
+ interpolation: BoneInterpolation;
18
+ time: number;
19
+ }
20
+ export interface MorphKeyframe {
21
+ morphName: string;
22
+ frame: number;
23
+ weight: number;
24
+ time: number;
25
+ }
26
+ /** Immutable clip data for one animation (e.g. one VMD). */
27
+ export interface AnimationClip {
28
+ boneTracks: Map<string, BoneKeyframe[]>;
29
+ morphTracks: Map<string, MorphKeyframe[]>;
30
+ duration: number;
31
+ /** When true, clip loops at end. When false, playback stops and onEnd fires. Default false. */
32
+ loop?: boolean;
33
+ }
34
+ /**
35
+ * Per-model animation state: multiple animations, non-interruptible playback.
36
+ * While one is playing, play(name) queues it to start when the current one finishes.
37
+ */
38
+ export declare class AnimationState {
39
+ private animations;
40
+ private currentAnimationName;
41
+ private currentTime;
42
+ private isPlaying;
43
+ private isPaused;
44
+ /** When current (non-loop) ends, play this next. Cleared when started. */
45
+ private nextAnimationName;
46
+ private onEnd;
47
+ /** Add or replace an animation by name. Does not start playback. */
48
+ loadAnimation(name: string, clip: AnimationClip): void;
49
+ /** Remove an animation. If it was current, state is cleared. */
50
+ removeAnimation(name: string): void;
51
+ /**
52
+ * Start playing an animation by name. Non-interruptible: if one is already playing,
53
+ * this animation is queued to start when the current one finishes.
54
+ */
55
+ play(name: string): boolean;
56
+ /** Resume current animation (no-op if none). */
57
+ play(): void;
58
+ /** Advance time. When a non-loop clip ends, starts nextAnimationName if set. */
59
+ update(deltaTime: number): {
60
+ ended: boolean;
61
+ animationName: string | null;
62
+ };
63
+ pause(): void;
64
+ stop(): void;
65
+ seek(time: number): void;
66
+ getCurrentClip(): AnimationClip | null;
67
+ getCurrentAnimation(): string | null;
68
+ getCurrentTime(): number;
69
+ getDuration(): number;
70
+ /** Progress of the current animation (time, duration, percentage). */
71
+ getProgress(): {
72
+ animationName: string | null;
73
+ current: number;
74
+ duration: number;
75
+ percentage: number;
76
+ };
77
+ getAnimationNames(): string[];
78
+ hasAnimation(name: string): boolean;
79
+ /** Show animation at time 0 without playing. Use after load when you want to play later (e.g. dance visualization). */
80
+ show(name: string): void;
81
+ setOnEnd(callback: ((animationName: string) => void) | null): void;
82
+ getPlaying(): boolean;
83
+ getPaused(): boolean;
84
+ }
11
85
  export declare function bezierInterpolate(x1: number, x2: number, y1: number, y2: number, t: number): number;
12
86
  export declare function rawInterpolationToBoneInterpolation(raw: Uint8Array): BoneInterpolation;
13
87
  export declare function interpolateControlPoints(cp: ControlPoint[], t: number): number;
@@ -1 +1 @@
1
- {"version":3,"file":"animation.d.ts","sourceRoot":"","sources":["../src/animation.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,YAAY,EAAE,YAAY,EAAE,CAAA;IAC5B,YAAY,EAAE,YAAY,EAAE,CAAA;IAC5B,YAAY,EAAE,YAAY,EAAE,CAAA;CAC7B;AAGD,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CA0BnG;AAKD,wBAAgB,mCAAmC,CAAC,GAAG,EAAE,UAAU,GAAG,iBAAiB,CAmBtF;AAGD,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,YAAY,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ9E"}
1
+ {"version":3,"file":"animation.d.ts","sourceRoot":"","sources":["../src/animation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAEnC,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,YAAY,EAAE,YAAY,EAAE,CAAA;IAC5B,YAAY,EAAE,YAAY,EAAE,CAAA;IAC5B,YAAY,EAAE,YAAY,EAAE,CAAA;CAC7B;AAGD,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,IAAI,CAAA;IACd,WAAW,EAAE,IAAI,CAAA;IACjB,aAAa,EAAE,iBAAiB,CAAA;IAChC,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;CACb;AAED,4DAA4D;AAC5D,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAA;IACvC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAA;IACzC,QAAQ,EAAE,MAAM,CAAA;IAChB,+FAA+F;IAC/F,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED;;;GAGG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,oBAAoB,CAAsB;IAClD,OAAO,CAAC,WAAW,CAAI;IACvB,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,QAAQ,CAAQ;IACxB,0EAA0E;IAC1E,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,KAAK,CAAiD;IAE9D,oEAAoE;IACpE,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;IAItD,gEAAgE;IAChE,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAYnC;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAC3B,gDAAgD;IAChD,IAAI,IAAI,IAAI;IAsBZ,gFAAgF;IAChF,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;IAiC3E,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI;IAOZ,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMxB,cAAc,IAAI,aAAa,GAAG,IAAI;IAItC,mBAAmB,IAAI,MAAM,GAAG,IAAI;IAIpC,cAAc,IAAI,MAAM;IAIxB,WAAW,IAAI,MAAM;IAKrB,sEAAsE;IACtE,WAAW,IAAI;QAAE,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE;IAYtG,iBAAiB,IAAI,MAAM,EAAE;IAI7B,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAInC,uHAAuH;IACvH,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IASxB,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,aAAa,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI;IAIlE,UAAU,IAAI,OAAO;IAIrB,SAAS,IAAI,OAAO;CAGrB;AAGD,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CA0BnG;AAKD,wBAAgB,mCAAmC,CAAC,GAAG,EAAE,UAAU,GAAG,iBAAiB,CAmBtF;AAGD,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,YAAY,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAQ9E"}
package/dist/animation.js CHANGED
@@ -1,3 +1,154 @@
1
+ /**
2
+ * Per-model animation state: multiple animations, non-interruptible playback.
3
+ * While one is playing, play(name) queues it to start when the current one finishes.
4
+ */
5
+ export class AnimationState {
6
+ constructor() {
7
+ this.animations = new Map();
8
+ this.currentAnimationName = null;
9
+ this.currentTime = 0;
10
+ this.isPlaying = false;
11
+ this.isPaused = false;
12
+ /** When current (non-loop) ends, play this next. Cleared when started. */
13
+ this.nextAnimationName = null;
14
+ this.onEnd = null;
15
+ }
16
+ /** Add or replace an animation by name. Does not start playback. */
17
+ loadAnimation(name, clip) {
18
+ this.animations.set(name, clip);
19
+ }
20
+ /** Remove an animation. If it was current, state is cleared. */
21
+ removeAnimation(name) {
22
+ this.animations.delete(name);
23
+ if (this.currentAnimationName === name) {
24
+ this.currentAnimationName = null;
25
+ this.currentTime = 0;
26
+ this.isPlaying = false;
27
+ this.nextAnimationName = this.nextAnimationName === name ? null : this.nextAnimationName;
28
+ }
29
+ else if (this.nextAnimationName === name) {
30
+ this.nextAnimationName = null;
31
+ }
32
+ }
33
+ play(name) {
34
+ if (name === undefined) {
35
+ if (this.currentAnimationName && this.animations.has(this.currentAnimationName)) {
36
+ this.isPaused = false;
37
+ this.isPlaying = true;
38
+ }
39
+ return;
40
+ }
41
+ if (!this.animations.has(name))
42
+ return false;
43
+ if (this.isPlaying && !this.isPaused) {
44
+ this.nextAnimationName = name;
45
+ return true;
46
+ }
47
+ this.currentAnimationName = name;
48
+ this.currentTime = 0;
49
+ this.isPlaying = true;
50
+ this.isPaused = false;
51
+ this.nextAnimationName = null;
52
+ return true;
53
+ }
54
+ /** Advance time. When a non-loop clip ends, starts nextAnimationName if set. */
55
+ update(deltaTime) {
56
+ if (!this.isPlaying || this.isPaused || this.currentAnimationName === null) {
57
+ return { ended: false, animationName: this.currentAnimationName };
58
+ }
59
+ const clip = this.animations.get(this.currentAnimationName);
60
+ if (!clip)
61
+ return { ended: false, animationName: this.currentAnimationName };
62
+ this.currentTime += deltaTime;
63
+ const duration = clip.duration;
64
+ if (this.currentTime >= duration) {
65
+ this.currentTime = duration;
66
+ if (clip.loop) {
67
+ this.currentTime = 0;
68
+ return { ended: false, animationName: this.currentAnimationName };
69
+ }
70
+ const finishedName = this.currentAnimationName;
71
+ this.onEnd?.(finishedName);
72
+ if (this.nextAnimationName !== null) {
73
+ const next = this.nextAnimationName;
74
+ this.nextAnimationName = null;
75
+ this.currentAnimationName = next;
76
+ this.currentTime = 0;
77
+ this.isPlaying = true;
78
+ this.isPaused = false;
79
+ return { ended: true, animationName: finishedName };
80
+ }
81
+ this.isPlaying = false;
82
+ return { ended: true, animationName: finishedName };
83
+ }
84
+ return { ended: false, animationName: this.currentAnimationName };
85
+ }
86
+ pause() {
87
+ this.isPaused = true;
88
+ }
89
+ stop() {
90
+ this.isPlaying = false;
91
+ this.isPaused = false;
92
+ this.currentTime = 0;
93
+ this.nextAnimationName = null;
94
+ }
95
+ seek(time) {
96
+ const clip = this.getCurrentClip();
97
+ if (!clip)
98
+ return;
99
+ this.currentTime = Math.max(0, Math.min(time, clip.duration));
100
+ }
101
+ getCurrentClip() {
102
+ return this.currentAnimationName !== null ? this.animations.get(this.currentAnimationName) ?? null : null;
103
+ }
104
+ getCurrentAnimation() {
105
+ return this.currentAnimationName;
106
+ }
107
+ getCurrentTime() {
108
+ return this.currentTime;
109
+ }
110
+ getDuration() {
111
+ const clip = this.getCurrentClip();
112
+ return clip ? clip.duration : 0;
113
+ }
114
+ /** Progress of the current animation (time, duration, percentage). */
115
+ getProgress() {
116
+ const clip = this.getCurrentClip();
117
+ const duration = clip ? clip.duration : 0;
118
+ const percentage = duration > 0 ? (this.currentTime / duration) * 100 : 0;
119
+ return {
120
+ animationName: this.currentAnimationName,
121
+ current: this.currentTime,
122
+ duration,
123
+ percentage,
124
+ };
125
+ }
126
+ getAnimationNames() {
127
+ return Array.from(this.animations.keys());
128
+ }
129
+ hasAnimation(name) {
130
+ return this.animations.has(name);
131
+ }
132
+ /** Show animation at time 0 without playing. Use after load when you want to play later (e.g. dance visualization). */
133
+ show(name) {
134
+ if (!this.animations.has(name))
135
+ return;
136
+ this.currentAnimationName = name;
137
+ this.currentTime = 0;
138
+ this.isPlaying = false;
139
+ this.isPaused = false;
140
+ this.nextAnimationName = null;
141
+ }
142
+ setOnEnd(callback) {
143
+ this.onEnd = callback;
144
+ }
145
+ getPlaying() {
146
+ return this.isPlaying;
147
+ }
148
+ getPaused() {
149
+ return this.isPaused;
150
+ }
151
+ }
1
152
  // Cubic bezier in normalized 0–1 space (binary search on x)
2
153
  export function bezierInterpolate(x1, x2, y1, y2, t) {
3
154
  t = Math.max(0, Math.min(1, t));
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Engine, type EngineStats } from "./engine";
2
2
  export { Model } from "./model";
3
3
  export { Vec3, Quat, Mat4 } from "./math";
4
+ export { AnimationState, type AnimationClip, type BoneKeyframe, type MorphKeyframe, } from "./animation";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAC/B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAC/B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AACzC,OAAO,EACL,cAAc,EACd,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { Engine } from "./engine";
2
2
  export { Model } from "./model";
3
3
  export { Vec3, Quat, Mat4 } from "./math";
4
+ export { AnimationState, } from "./animation";
package/dist/model.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Mat4, Quat, Vec3 } from "./math";
2
2
  import { Rigidbody, Joint } from "./physics";
3
+ import { AnimationState } from "./animation";
3
4
  export interface Texture {
4
5
  path: string;
5
6
  name: string;
@@ -119,15 +120,10 @@ export declare class Model {
119
120
  private skinMatricesArray?;
120
121
  private tweenState;
121
122
  private tweenTimeMs;
122
- private _hasAnimation;
123
- private boneTracks;
124
- private morphTracks;
125
- private animationDuration;
126
- private isPlaying;
127
- private isPaused;
128
- private animationTime;
123
+ private readonly animationState;
129
124
  private boneTrackIndices;
130
125
  private morphTrackIndices;
126
+ private lastAppliedClip;
131
127
  private ikEnabled;
132
128
  private physicsEnabled;
133
129
  constructor(vertexData: Float32Array<ArrayBuffer>, indexData: Uint32Array<ArrayBuffer>, textures: Texture[], materials: Material[], skeleton: Skeleton, skinning: Skinning, morphing: Morphing, rigidbodies?: Rigidbody[], joints?: Joint[]);
@@ -156,24 +152,46 @@ export declare class Model {
156
152
  getSkinMatrices(): Float32Array;
157
153
  setMorphWeight(name: string, weight: number, durationMs?: number): void;
158
154
  private applyMorphs;
155
+ /** Build an AnimationClip from VMD keyframes (used by loadVmd and loadAnimation). */
156
+ private buildClipFromVmdKeyFrames;
157
+ /** Load one VMD as the "default" animation and show first frame. Does not auto-play; call playAnimation() when needed. */
159
158
  loadVmd(vmdUrl: string): Promise<void>;
159
+ /** Load a VMD as a named animation (e.g. "idle", "walk", "attack"). Does not start playback. */
160
+ loadAnimation(animationName: string, vmdUrl: string): Promise<void>;
160
161
  resetAllBones(): void;
161
162
  resetAllMorphs(): void;
162
163
  setIKEnabled(enabled: boolean): void;
163
164
  setPhysicsEnabled(enabled: boolean): void;
164
165
  getPhysicsEnabled(): boolean;
166
+ /** Low-level access to animation state when needed. Prefer model.play(), model.show(), etc. */
167
+ getAnimationState(): AnimationState;
168
+ /** Resume current animation (no-op if none). */
169
+ play(): void;
170
+ /** Play named animation; if one is already playing, it is queued. Returns false if name not loaded. */
171
+ play(name: string): boolean;
172
+ /** Show named animation at time 0 without playing. Use after load when you want to play later. */
173
+ show(name: string): void;
174
+ /** @deprecated Use model.play() */
165
175
  playAnimation(): void;
176
+ pause(): void;
177
+ /** @deprecated Use model.pause() */
166
178
  pauseAnimation(): void;
179
+ stop(): void;
180
+ /** @deprecated Use model.stop() */
167
181
  stopAnimation(): void;
182
+ seek(time: number): void;
183
+ /** @deprecated Use model.seek() */
168
184
  seekAnimation(time: number): void;
169
185
  getAnimationProgress(): {
170
186
  current: number;
171
187
  duration: number;
172
188
  percentage: number;
189
+ animationName: string | null;
173
190
  };
174
191
  private static upperBound;
175
192
  private findKeyframeIndex;
176
- private getPoseAtTime;
193
+ /** Apply pose from a clip at the given time. No-op if clip is null. */
194
+ private applyPoseFromClip;
177
195
  update(deltaTime: number): boolean;
178
196
  private solveIKChains;
179
197
  private ikComputedSet;
@@ -1 +1 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
package/dist/model.js CHANGED
@@ -3,7 +3,7 @@ import { Engine } from "./engine";
3
3
  import { PmxLoader } from "./pmx-loader";
4
4
  import { IKSolverSystem } from "./ik-solver";
5
5
  import { VMDLoader } from "./vmd-loader";
6
- import { interpolateControlPoints, rawInterpolationToBoneInterpolation } from "./animation";
6
+ import { AnimationState, interpolateControlPoints, rawInterpolationToBoneInterpolation, } from "./animation";
7
7
  const VMD_FPS = 30;
8
8
  const VERTEX_STRIDE = 8;
9
9
  export class Model {
@@ -34,17 +34,11 @@ export class Model {
34
34
  this.cachedIdentityMat1 = Mat4.identity();
35
35
  this.cachedIdentityMat2 = Mat4.identity();
36
36
  this.tweenTimeMs = 0; // Time tracking for tweens (milliseconds)
37
- // Animation runtime
38
- this._hasAnimation = false;
39
- this.boneTracks = new Map();
40
- this.morphTracks = new Map();
41
- this.animationDuration = 0;
42
- this.isPlaying = false;
43
- this.isPaused = false;
44
- this.animationTime = 0; // Current time in animation (seconds)
45
- // Cached keyframe indices for faster lookup (per track)
37
+ // Animation: state and multiple slots (idle, walk, attack, etc.); commit/rollback for action-game style
38
+ this.animationState = new AnimationState();
46
39
  this.boneTrackIndices = new Map();
47
40
  this.morphTrackIndices = new Map();
41
+ this.lastAppliedClip = null;
48
42
  // IK and Physics enable flags
49
43
  this.ikEnabled = true;
50
44
  this.physicsEnabled = true;
@@ -543,11 +537,8 @@ export class Model {
543
537
  }
544
538
  }
545
539
  }
546
- async loadVmd(vmdUrl) {
547
- const vmdKeyFrames = await VMDLoader.load(vmdUrl);
548
- this.resetAllBones();
549
- this.resetAllMorphs();
550
- // Build bone tracks: Map<boneName, Array<{boneName, frame, rotation, translation, interpolation, time}>>
540
+ /** Build an AnimationClip from VMD keyframes (used by loadVmd and loadAnimation). */
541
+ buildClipFromVmdKeyFrames(vmdKeyFrames) {
551
542
  const boneTracksByBone = {};
552
543
  for (const keyFrame of vmdKeyFrames) {
553
544
  for (const bf of keyFrame.boneFrames) {
@@ -561,11 +552,11 @@ export class Model {
561
552
  });
562
553
  }
563
554
  }
564
- this.boneTracks = new Map();
555
+ const boneTracks = new Map();
565
556
  for (const name in boneTracksByBone) {
566
557
  const keyframes = boneTracksByBone[name];
567
558
  const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
568
- this.boneTracks.set(name, sorted.map((kf) => ({
559
+ boneTracks.set(name, sorted.map((kf) => ({
569
560
  boneName: name,
570
561
  frame: kf.frame,
571
562
  rotation: kf.rotation,
@@ -574,7 +565,6 @@ export class Model {
574
565
  time: kf.frame / VMD_FPS,
575
566
  })));
576
567
  }
577
- // Build morph tracks: Map<morphName, Array<{morphName, frame, weight, time}>>
578
568
  const morphTracksByMorph = {};
579
569
  for (const keyFrame of vmdKeyFrames) {
580
570
  for (const mf of keyFrame.morphFrames) {
@@ -583,33 +573,43 @@ export class Model {
583
573
  morphTracksByMorph[mf.morphName].push({ frame: mf.frame, weight: mf.weight });
584
574
  }
585
575
  }
586
- this.morphTracks = new Map();
576
+ const morphTracks = new Map();
587
577
  for (const name in morphTracksByMorph) {
588
578
  const keyframes = morphTracksByMorph[name];
589
579
  const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
590
- this.morphTracks.set(name, sorted.map((kf) => ({
580
+ morphTracks.set(name, sorted.map((kf) => ({
591
581
  morphName: name,
592
582
  frame: kf.frame,
593
583
  weight: kf.weight,
594
584
  time: kf.frame / VMD_FPS,
595
585
  })));
596
586
  }
597
- this.boneTrackIndices.clear();
598
- this.morphTrackIndices.clear();
599
- // Calculate duration
600
587
  let maxTime = 0;
601
- for (const frames of this.boneTracks.values()) {
588
+ for (const frames of boneTracks.values()) {
602
589
  if (frames.length > 0)
603
590
  maxTime = Math.max(maxTime, frames[frames.length - 1].time);
604
591
  }
605
- for (const frames of this.morphTracks.values()) {
592
+ for (const frames of morphTracks.values()) {
606
593
  if (frames.length > 0)
607
594
  maxTime = Math.max(maxTime, frames[frames.length - 1].time);
608
595
  }
609
- this.animationDuration = maxTime;
610
- this._hasAnimation = true;
611
- this.animationTime = 0;
612
- this.getPoseAtTime(0);
596
+ return { boneTracks, morphTracks, duration: maxTime };
597
+ }
598
+ /** Load one VMD as the "default" animation and show first frame. Does not auto-play; call playAnimation() when needed. */
599
+ async loadVmd(vmdUrl) {
600
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl);
601
+ this.resetAllBones();
602
+ this.resetAllMorphs();
603
+ const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames);
604
+ this.animationState.loadAnimation("default", clip);
605
+ this.animationState.show("default");
606
+ this.applyPoseFromClip(this.animationState.getCurrentClip(), 0);
607
+ }
608
+ /** Load a VMD as a named animation (e.g. "idle", "walk", "attack"). Does not start playback. */
609
+ async loadAnimation(animationName, vmdUrl) {
610
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl);
611
+ const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames);
612
+ this.animationState.loadAnimation(animationName, clip);
613
613
  }
614
614
  resetAllBones() {
615
615
  for (let boneIdx = 0; boneIdx < this.skeleton.bones.length; boneIdx++) {
@@ -638,36 +638,49 @@ export class Model {
638
638
  getPhysicsEnabled() {
639
639
  return this.physicsEnabled;
640
640
  }
641
- playAnimation() {
642
- if (!this._hasAnimation)
641
+ /** Low-level access to animation state when needed. Prefer model.play(), model.show(), etc. */
642
+ getAnimationState() {
643
+ return this.animationState;
644
+ }
645
+ play(name) {
646
+ if (name === undefined) {
647
+ this.animationState.play();
643
648
  return;
644
- this.isPaused = false;
645
- this.isPlaying = true;
649
+ }
650
+ return this.animationState.play(name);
646
651
  }
652
+ /** Show named animation at time 0 without playing. Use after load when you want to play later. */
653
+ show(name) {
654
+ this.animationState.show(name);
655
+ }
656
+ /** @deprecated Use model.play() */
657
+ playAnimation() {
658
+ this.animationState.play();
659
+ }
660
+ pause() {
661
+ this.animationState.pause();
662
+ }
663
+ /** @deprecated Use model.pause() */
647
664
  pauseAnimation() {
648
- if (!this.isPlaying || this.isPaused)
649
- return;
650
- this.isPaused = true;
665
+ this.animationState.pause();
666
+ }
667
+ stop() {
668
+ this.animationState.stop();
651
669
  }
670
+ /** @deprecated Use model.stop() */
652
671
  stopAnimation() {
653
- this.isPlaying = false;
654
- this.isPaused = false;
655
- this.animationTime = 0;
672
+ this.animationState.stop();
673
+ }
674
+ seek(time) {
675
+ this.animationState.seek(time);
656
676
  }
677
+ /** @deprecated Use model.seek() */
657
678
  seekAnimation(time) {
658
- if (!this._hasAnimation)
659
- return;
660
- const clampedTime = Math.max(0, Math.min(time, this.animationDuration));
661
- this.animationTime = clampedTime;
679
+ this.animationState.seek(time);
662
680
  }
663
681
  getAnimationProgress() {
664
- const duration = this.animationDuration;
665
- const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0;
666
- return {
667
- current: this.animationTime,
668
- duration,
669
- percentage,
670
- };
682
+ const p = this.animationState.getProgress();
683
+ return { current: p.current, duration: p.duration, percentage: p.percentage, animationName: p.animationName };
671
684
  }
672
685
  static upperBound(time, keyFrames, startIdx = 0) {
673
686
  let left = startIdx, right = keyFrames.length;
@@ -696,11 +709,16 @@ export class Model {
696
709
  const idx = Model.upperBound(time, keyFrames, 0) - 1;
697
710
  return idx;
698
711
  }
699
- getPoseAtTime(time) {
700
- if (!this._hasAnimation)
712
+ /** Apply pose from a clip at the given time. No-op if clip is null. */
713
+ applyPoseFromClip(clip, time) {
714
+ if (!clip)
701
715
  return;
702
- // Process bone tracks
703
- for (const [boneName, keyFrames] of this.boneTracks.entries()) {
716
+ if (clip !== this.lastAppliedClip) {
717
+ this.boneTrackIndices.clear();
718
+ this.morphTrackIndices.clear();
719
+ this.lastAppliedClip = clip;
720
+ }
721
+ for (const [boneName, keyFrames] of clip.boneTracks.entries()) {
704
722
  if (keyFrames.length === 0)
705
723
  continue;
706
724
  const cachedIdx = this.boneTrackIndices.get(boneName) ?? -1;
@@ -737,8 +755,7 @@ export class Model {
737
755
  localTrans.set(localTranslation);
738
756
  }
739
757
  }
740
- // Process morph tracks
741
- for (const [morphName, keyFrames] of this.morphTracks.entries()) {
758
+ for (const [morphName, keyFrames] of clip.morphTracks.entries()) {
742
759
  if (keyFrames.length === 0)
743
760
  continue;
744
761
  const cachedIdx = this.morphTrackIndices.get(morphName) ?? -1;
@@ -767,20 +784,11 @@ export class Model {
767
784
  this.tweenTimeMs += deltaTime * 1000;
768
785
  // Update all active tweens (rotations, translations, morphs)
769
786
  const tweensChangedMorphs = this.updateTweens();
770
- // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
771
- if (this._hasAnimation) {
772
- if (this.isPlaying && !this.isPaused) {
773
- this.animationTime += deltaTime;
774
- if (this.animationTime >= this.animationDuration) {
775
- this.animationTime = this.animationDuration;
776
- this.pauseAnimation(); // Auto-pause at end
777
- }
778
- this.getPoseAtTime(this.animationTime);
779
- }
780
- else if (this.isPaused || (!this.isPlaying && this.animationTime >= 0)) {
781
- // Apply pose at paused time or if we have a seeked time but not playing
782
- this.getPoseAtTime(this.animationTime);
783
- }
787
+ this.animationState.update(deltaTime);
788
+ const clip = this.animationState.getCurrentClip();
789
+ const time = this.animationState.getCurrentTime();
790
+ if (clip !== null) {
791
+ this.applyPoseFromClip(clip, time);
784
792
  }
785
793
  // Apply morphs if tweens changed morphs or animation changed morphs
786
794
  const verticesChanged = this.morphsDirty || tweensChangedMorphs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "A WebGPU-based MMD model renderer",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/animation.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Quat, Vec3 } from "./math"
2
+
1
3
  export interface ControlPoint {
2
4
  x: number
3
5
  y: number
@@ -10,6 +12,204 @@ export interface BoneInterpolation {
10
12
  translationZ: ControlPoint[]
11
13
  }
12
14
 
15
+ // Keyframe types for animation clips (used by Model and AnimationState)
16
+ export interface BoneKeyframe {
17
+ boneName: string
18
+ frame: number
19
+ rotation: Quat
20
+ translation: Vec3
21
+ interpolation: BoneInterpolation
22
+ time: number
23
+ }
24
+
25
+ export interface MorphKeyframe {
26
+ morphName: string
27
+ frame: number
28
+ weight: number
29
+ time: number
30
+ }
31
+
32
+ /** Immutable clip data for one animation (e.g. one VMD). */
33
+ export interface AnimationClip {
34
+ boneTracks: Map<string, BoneKeyframe[]>
35
+ morphTracks: Map<string, MorphKeyframe[]>
36
+ duration: number
37
+ /** When true, clip loops at end. When false, playback stops and onEnd fires. Default false. */
38
+ loop?: boolean
39
+ }
40
+
41
+ /**
42
+ * Per-model animation state: multiple animations, non-interruptible playback.
43
+ * While one is playing, play(name) queues it to start when the current one finishes.
44
+ */
45
+ export class AnimationState {
46
+ private animations = new Map<string, AnimationClip>()
47
+ private currentAnimationName: string | null = null
48
+ private currentTime = 0
49
+ private isPlaying = false
50
+ private isPaused = false
51
+ /** When current (non-loop) ends, play this next. Cleared when started. */
52
+ private nextAnimationName: string | null = null
53
+ private onEnd: ((animationName: string) => void) | null = null
54
+
55
+ /** Add or replace an animation by name. Does not start playback. */
56
+ loadAnimation(name: string, clip: AnimationClip): void {
57
+ this.animations.set(name, clip)
58
+ }
59
+
60
+ /** Remove an animation. If it was current, state is cleared. */
61
+ removeAnimation(name: string): void {
62
+ this.animations.delete(name)
63
+ if (this.currentAnimationName === name) {
64
+ this.currentAnimationName = null
65
+ this.currentTime = 0
66
+ this.isPlaying = false
67
+ this.nextAnimationName = this.nextAnimationName === name ? null : this.nextAnimationName
68
+ } else if (this.nextAnimationName === name) {
69
+ this.nextAnimationName = null
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Start playing an animation by name. Non-interruptible: if one is already playing,
75
+ * this animation is queued to start when the current one finishes.
76
+ */
77
+ play(name: string): boolean
78
+ /** Resume current animation (no-op if none). */
79
+ play(): void
80
+ play(name?: string): boolean | void {
81
+ if (name === undefined) {
82
+ if (this.currentAnimationName && this.animations.has(this.currentAnimationName)) {
83
+ this.isPaused = false
84
+ this.isPlaying = true
85
+ }
86
+ return
87
+ }
88
+ if (!this.animations.has(name)) return false
89
+ if (this.isPlaying && !this.isPaused) {
90
+ this.nextAnimationName = name
91
+ return true
92
+ }
93
+ this.currentAnimationName = name
94
+ this.currentTime = 0
95
+ this.isPlaying = true
96
+ this.isPaused = false
97
+ this.nextAnimationName = null
98
+ return true
99
+ }
100
+
101
+ /** Advance time. When a non-loop clip ends, starts nextAnimationName if set. */
102
+ update(deltaTime: number): { ended: boolean; animationName: string | null } {
103
+ if (!this.isPlaying || this.isPaused || this.currentAnimationName === null) {
104
+ return { ended: false, animationName: this.currentAnimationName }
105
+ }
106
+ const clip = this.animations.get(this.currentAnimationName)
107
+ if (!clip) return { ended: false, animationName: this.currentAnimationName }
108
+
109
+ this.currentTime += deltaTime
110
+ const duration = clip.duration
111
+
112
+ if (this.currentTime >= duration) {
113
+ this.currentTime = duration
114
+ if (clip.loop) {
115
+ this.currentTime = 0
116
+ return { ended: false, animationName: this.currentAnimationName }
117
+ }
118
+ const finishedName = this.currentAnimationName
119
+ this.onEnd?.(finishedName)
120
+ if (this.nextAnimationName !== null) {
121
+ const next = this.nextAnimationName
122
+ this.nextAnimationName = null
123
+ this.currentAnimationName = next
124
+ this.currentTime = 0
125
+ this.isPlaying = true
126
+ this.isPaused = false
127
+ return { ended: true, animationName: finishedName }
128
+ }
129
+ this.isPlaying = false
130
+ return { ended: true, animationName: finishedName }
131
+ }
132
+ return { ended: false, animationName: this.currentAnimationName }
133
+ }
134
+
135
+ pause(): void {
136
+ this.isPaused = true
137
+ }
138
+
139
+ stop(): void {
140
+ this.isPlaying = false
141
+ this.isPaused = false
142
+ this.currentTime = 0
143
+ this.nextAnimationName = null
144
+ }
145
+
146
+ seek(time: number): void {
147
+ const clip = this.getCurrentClip()
148
+ if (!clip) return
149
+ this.currentTime = Math.max(0, Math.min(time, clip.duration))
150
+ }
151
+
152
+ getCurrentClip(): AnimationClip | null {
153
+ return this.currentAnimationName !== null ? this.animations.get(this.currentAnimationName) ?? null : null
154
+ }
155
+
156
+ getCurrentAnimation(): string | null {
157
+ return this.currentAnimationName
158
+ }
159
+
160
+ getCurrentTime(): number {
161
+ return this.currentTime
162
+ }
163
+
164
+ getDuration(): number {
165
+ const clip = this.getCurrentClip()
166
+ return clip ? clip.duration : 0
167
+ }
168
+
169
+ /** Progress of the current animation (time, duration, percentage). */
170
+ getProgress(): { animationName: string | null; current: number; duration: number; percentage: number } {
171
+ const clip = this.getCurrentClip()
172
+ const duration = clip ? clip.duration : 0
173
+ const percentage = duration > 0 ? (this.currentTime / duration) * 100 : 0
174
+ return {
175
+ animationName: this.currentAnimationName,
176
+ current: this.currentTime,
177
+ duration,
178
+ percentage,
179
+ }
180
+ }
181
+
182
+ getAnimationNames(): string[] {
183
+ return Array.from(this.animations.keys())
184
+ }
185
+
186
+ hasAnimation(name: string): boolean {
187
+ return this.animations.has(name)
188
+ }
189
+
190
+ /** Show animation at time 0 without playing. Use after load when you want to play later (e.g. dance visualization). */
191
+ show(name: string): void {
192
+ if (!this.animations.has(name)) return
193
+ this.currentAnimationName = name
194
+ this.currentTime = 0
195
+ this.isPlaying = false
196
+ this.isPaused = false
197
+ this.nextAnimationName = null
198
+ }
199
+
200
+ setOnEnd(callback: ((animationName: string) => void) | null): void {
201
+ this.onEnd = callback
202
+ }
203
+
204
+ getPlaying(): boolean {
205
+ return this.isPlaying
206
+ }
207
+
208
+ getPaused(): boolean {
209
+ return this.isPaused
210
+ }
211
+ }
212
+
13
213
  // Cubic bezier in normalized 0–1 space (binary search on x)
14
214
  export function bezierInterpolate(x1: number, x2: number, y1: number, y2: number, t: number): number {
15
215
  t = Math.max(0, Math.min(1, t))
package/src/index.ts CHANGED
@@ -1,3 +1,9 @@
1
1
  export { Engine, type EngineStats } from "./engine"
2
2
  export { Model } from "./model"
3
3
  export { Vec3, Quat, Mat4 } from "./math"
4
+ export {
5
+ AnimationState,
6
+ type AnimationClip,
7
+ type BoneKeyframe,
8
+ type MorphKeyframe,
9
+ } from "./animation"
package/src/model.ts CHANGED
@@ -3,8 +3,16 @@ import { Engine } from "./engine"
3
3
  import { PmxLoader } from "./pmx-loader"
4
4
  import { Rigidbody, Joint } from "./physics"
5
5
  import { IKSolverSystem } from "./ik-solver"
6
- import { VMDLoader } from "./vmd-loader"
7
- import { BoneInterpolation, interpolateControlPoints, rawInterpolationToBoneInterpolation } from "./animation"
6
+ import { VMDLoader, type VMDKeyFrame } from "./vmd-loader"
7
+ import {
8
+ AnimationClip,
9
+ AnimationState,
10
+ BoneInterpolation,
11
+ BoneKeyframe,
12
+ MorphKeyframe,
13
+ interpolateControlPoints,
14
+ rawInterpolationToBoneInterpolation,
15
+ } from "./animation"
8
16
 
9
17
  const VMD_FPS = 30
10
18
  const VERTEX_STRIDE = 8
@@ -206,18 +214,11 @@ export class Model {
206
214
  private tweenState!: TweenState
207
215
  private tweenTimeMs: number = 0 // Time tracking for tweens (milliseconds)
208
216
 
209
- // Animation runtime
210
- private _hasAnimation: boolean = false
211
- private boneTracks: Map<string, Array<{ boneName: string; frame: number; rotation: Quat; translation: Vec3; interpolation: BoneInterpolation; time: number }>> = new Map()
212
- private morphTracks: Map<string, Array<{ morphName: string; frame: number; weight: number; time: number }>> = new Map()
213
- private animationDuration: number = 0
214
- private isPlaying: boolean = false
215
- private isPaused: boolean = false
216
- private animationTime: number = 0 // Current time in animation (seconds)
217
-
218
- // Cached keyframe indices for faster lookup (per track)
217
+ // Animation: state and multiple slots (idle, walk, attack, etc.); commit/rollback for action-game style
218
+ private readonly animationState = new AnimationState()
219
219
  private boneTrackIndices: Map<string, number> = new Map()
220
220
  private morphTrackIndices: Map<string, number> = new Map()
221
+ private lastAppliedClip: AnimationClip | null = null
221
222
 
222
223
  // IK and Physics enable flags
223
224
  private ikEnabled = true
@@ -815,13 +816,8 @@ export class Model {
815
816
  }
816
817
  }
817
818
 
818
- async loadVmd(vmdUrl: string): Promise<void> {
819
- const vmdKeyFrames = await VMDLoader.load(vmdUrl)
820
-
821
- this.resetAllBones()
822
- this.resetAllMorphs()
823
-
824
- // Build bone tracks: Map<boneName, Array<{boneName, frame, rotation, translation, interpolation, time}>>
819
+ /** Build an AnimationClip from VMD keyframes (used by loadVmd and loadAnimation). */
820
+ private buildClipFromVmdKeyFrames(vmdKeyFrames: VMDKeyFrame[]): AnimationClip {
825
821
  const boneTracksByBone: Record<string, Array<{ frame: number; rotation: Quat; translation: Vec3; interpolation: BoneInterpolation }>> = {}
826
822
  for (const keyFrame of vmdKeyFrames) {
827
823
  for (const bf of keyFrame.boneFrames) {
@@ -834,12 +830,11 @@ export class Model {
834
830
  })
835
831
  }
836
832
  }
837
-
838
- this.boneTracks = new Map()
833
+ const boneTracks = new Map<string, BoneKeyframe[]>()
839
834
  for (const name in boneTracksByBone) {
840
835
  const keyframes = boneTracksByBone[name]
841
836
  const sorted = [...keyframes].sort((a, b) => a.frame - b.frame)
842
- this.boneTracks.set(
837
+ boneTracks.set(
843
838
  name,
844
839
  sorted.map((kf) => ({
845
840
  boneName: name,
@@ -851,8 +846,6 @@ export class Model {
851
846
  }))
852
847
  )
853
848
  }
854
-
855
- // Build morph tracks: Map<morphName, Array<{morphName, frame, weight, time}>>
856
849
  const morphTracksByMorph: Record<string, Array<{ frame: number; weight: number }>> = {}
857
850
  for (const keyFrame of vmdKeyFrames) {
858
851
  for (const mf of keyFrame.morphFrames) {
@@ -860,12 +853,11 @@ export class Model {
860
853
  morphTracksByMorph[mf.morphName].push({ frame: mf.frame, weight: mf.weight })
861
854
  }
862
855
  }
863
-
864
- this.morphTracks = new Map()
856
+ const morphTracks = new Map<string, MorphKeyframe[]>()
865
857
  for (const name in morphTracksByMorph) {
866
858
  const keyframes = morphTracksByMorph[name]
867
859
  const sorted = [...keyframes].sort((a, b) => a.frame - b.frame)
868
- this.morphTracks.set(
860
+ morphTracks.set(
869
861
  name,
870
862
  sorted.map((kf) => ({
871
863
  morphName: name,
@@ -875,24 +867,32 @@ export class Model {
875
867
  }))
876
868
  )
877
869
  }
878
-
879
- this.boneTrackIndices.clear()
880
- this.morphTrackIndices.clear()
881
-
882
- // Calculate duration
883
870
  let maxTime = 0
884
- for (const frames of this.boneTracks.values()) {
871
+ for (const frames of boneTracks.values()) {
885
872
  if (frames.length > 0) maxTime = Math.max(maxTime, frames[frames.length - 1].time)
886
873
  }
887
- for (const frames of this.morphTracks.values()) {
874
+ for (const frames of morphTracks.values()) {
888
875
  if (frames.length > 0) maxTime = Math.max(maxTime, frames[frames.length - 1].time)
889
876
  }
890
- this.animationDuration = maxTime
877
+ return { boneTracks, morphTracks, duration: maxTime }
878
+ }
891
879
 
892
- this._hasAnimation = true
893
- this.animationTime = 0
894
- this.getPoseAtTime(0)
880
+ /** Load one VMD as the "default" animation and show first frame. Does not auto-play; call playAnimation() when needed. */
881
+ async loadVmd(vmdUrl: string): Promise<void> {
882
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl)
883
+ this.resetAllBones()
884
+ this.resetAllMorphs()
885
+ const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames)
886
+ this.animationState.loadAnimation("default", clip)
887
+ this.animationState.show("default")
888
+ this.applyPoseFromClip(this.animationState.getCurrentClip(), 0)
889
+ }
895
890
 
891
+ /** Load a VMD as a named animation (e.g. "idle", "walk", "attack"). Does not start playback. */
892
+ async loadAnimation(animationName: string, vmdUrl: string): Promise<void> {
893
+ const vmdKeyFrames = await VMDLoader.load(vmdUrl)
894
+ const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames)
895
+ this.animationState.loadAnimation(animationName, clip)
896
896
  }
897
897
 
898
898
  public resetAllBones(): void {
@@ -928,39 +928,63 @@ export class Model {
928
928
  return this.physicsEnabled
929
929
  }
930
930
 
931
- playAnimation(): void {
932
- if (!this._hasAnimation) return
931
+ /** Low-level access to animation state when needed. Prefer model.play(), model.show(), etc. */
932
+ getAnimationState(): AnimationState {
933
+ return this.animationState
934
+ }
935
+
936
+ /** Resume current animation (no-op if none). */
937
+ play(): void
938
+ /** Play named animation; if one is already playing, it is queued. Returns false if name not loaded. */
939
+ play(name: string): boolean
940
+ play(name?: string): void | boolean {
941
+ if (name === undefined) {
942
+ this.animationState.play()
943
+ return
944
+ }
945
+ return this.animationState.play(name)
946
+ }
933
947
 
934
- this.isPaused = false
935
- this.isPlaying = true
948
+ /** Show named animation at time 0 without playing. Use after load when you want to play later. */
949
+ show(name: string): void {
950
+ this.animationState.show(name)
951
+ }
936
952
 
953
+ /** @deprecated Use model.play() */
954
+ playAnimation(): void {
955
+ this.animationState.play()
937
956
  }
938
957
 
958
+ pause(): void {
959
+ this.animationState.pause()
960
+ }
961
+
962
+ /** @deprecated Use model.pause() */
939
963
  pauseAnimation(): void {
940
- if (!this.isPlaying || this.isPaused) return
941
- this.isPaused = true
964
+ this.animationState.pause()
942
965
  }
943
966
 
967
+ stop(): void {
968
+ this.animationState.stop()
969
+ }
970
+
971
+ /** @deprecated Use model.stop() */
944
972
  stopAnimation(): void {
945
- this.isPlaying = false
946
- this.isPaused = false
947
- this.animationTime = 0
973
+ this.animationState.stop()
974
+ }
975
+
976
+ seek(time: number): void {
977
+ this.animationState.seek(time)
948
978
  }
949
979
 
980
+ /** @deprecated Use model.seek() */
950
981
  seekAnimation(time: number): void {
951
- if (!this._hasAnimation) return
952
- const clampedTime = Math.max(0, Math.min(time, this.animationDuration))
953
- this.animationTime = clampedTime
982
+ this.animationState.seek(time)
954
983
  }
955
984
 
956
- getAnimationProgress(): { current: number; duration: number; percentage: number } {
957
- const duration = this.animationDuration
958
- const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0
959
- return {
960
- current: this.animationTime,
961
- duration,
962
- percentage,
963
- }
985
+ getAnimationProgress(): { current: number; duration: number; percentage: number; animationName: string | null } {
986
+ const p = this.animationState.getProgress()
987
+ return { current: p.current, duration: p.duration, percentage: p.percentage, animationName: p.animationName }
964
988
  }
965
989
 
966
990
  private static upperBound<T extends { time: number }>(time: number, keyFrames: T[], startIdx: number = 0): number {
@@ -993,11 +1017,16 @@ export class Model {
993
1017
  return idx
994
1018
  }
995
1019
 
996
- private getPoseAtTime(time: number): void {
997
- if (!this._hasAnimation) return
1020
+ /** Apply pose from a clip at the given time. No-op if clip is null. */
1021
+ private applyPoseFromClip(clip: AnimationClip | null, time: number): void {
1022
+ if (!clip) return
1023
+ if (clip !== this.lastAppliedClip) {
1024
+ this.boneTrackIndices.clear()
1025
+ this.morphTrackIndices.clear()
1026
+ this.lastAppliedClip = clip
1027
+ }
998
1028
 
999
- // Process bone tracks
1000
- for (const [boneName, keyFrames] of this.boneTracks.entries()) {
1029
+ for (const [boneName, keyFrames] of clip.boneTracks.entries()) {
1001
1030
  if (keyFrames.length === 0) continue
1002
1031
 
1003
1032
  const cachedIdx = this.boneTrackIndices.get(boneName) ?? -1
@@ -1047,8 +1076,7 @@ export class Model {
1047
1076
  }
1048
1077
  }
1049
1078
 
1050
- // Process morph tracks
1051
- for (const [morphName, keyFrames] of this.morphTracks.entries()) {
1079
+ for (const [morphName, keyFrames] of clip.morphTracks.entries()) {
1052
1080
  if (keyFrames.length === 0) continue
1053
1081
 
1054
1082
  const cachedIdx = this.morphTrackIndices.get(morphName) ?? -1
@@ -1084,21 +1112,11 @@ export class Model {
1084
1112
  // Update all active tweens (rotations, translations, morphs)
1085
1113
  const tweensChangedMorphs = this.updateTweens()
1086
1114
 
1087
- // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
1088
- if (this._hasAnimation) {
1089
- if (this.isPlaying && !this.isPaused) {
1090
- this.animationTime += deltaTime
1091
-
1092
- if (this.animationTime >= this.animationDuration) {
1093
- this.animationTime = this.animationDuration
1094
- this.pauseAnimation() // Auto-pause at end
1095
- }
1096
-
1097
- this.getPoseAtTime(this.animationTime)
1098
- } else if (this.isPaused || (!this.isPlaying && this.animationTime >= 0)) {
1099
- // Apply pose at paused time or if we have a seeked time but not playing
1100
- this.getPoseAtTime(this.animationTime)
1101
- }
1115
+ this.animationState.update(deltaTime)
1116
+ const clip = this.animationState.getCurrentClip()
1117
+ const time = this.animationState.getCurrentTime()
1118
+ if (clip !== null) {
1119
+ this.applyPoseFromClip(clip, time)
1102
1120
  }
1103
1121
 
1104
1122
  // Apply morphs if tweens changed morphs or animation changed morphs