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 +13 -91
- package/dist/animation.d.ts +74 -0
- package/dist/animation.d.ts.map +1 -1
- package/dist/animation.js +151 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/model.d.ts +26 -8
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +78 -70
- package/package.json +1 -1
- package/src/animation.ts +200 -0
- package/src/index.ts +6 -0
- package/src/model.ts +97 -79
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
|
-

|
|
6
|
-
|
|
7
5
|
## Features
|
|
8
6
|
|
|
9
|
-
- Blinn-Phong lighting
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
+
## API (summary)
|
|
103
26
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
package/dist/animation.d.ts
CHANGED
|
@@ -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;
|
package/dist/animation.d.ts.map
CHANGED
|
@@ -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":"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"}
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
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
|
|
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
|
-
|
|
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;
|
package/dist/model.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAGzC,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;
|
|
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
|
|
38
|
-
this.
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
645
|
-
this.
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
665
|
+
this.animationState.pause();
|
|
666
|
+
}
|
|
667
|
+
stop() {
|
|
668
|
+
this.animationState.stop();
|
|
651
669
|
}
|
|
670
|
+
/** @deprecated Use model.stop() */
|
|
652
671
|
stopAnimation() {
|
|
653
|
-
this.
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
|
665
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
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
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 {
|
|
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
|
|
210
|
-
private
|
|
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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
877
|
+
return { boneTracks, morphTracks, duration: maxTime }
|
|
878
|
+
}
|
|
891
879
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
932
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
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.
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
|
958
|
-
|
|
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
|
-
|
|
997
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|