reze-engine 0.30.2 → 0.31.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 +20 -1
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +22 -2
- package/dist/engine.d.ts +8 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +48 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/locomotion.d.ts +94 -0
- package/dist/locomotion.d.ts.map +1 -1
- package/dist/locomotion.js +332 -3
- package/dist/model.d.ts +23 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +59 -1
- package/dist/state-machine.d.ts +64 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +145 -0
- package/package.json +1 -1
- package/src/camera.ts +13 -2
- package/src/engine.ts +48 -4
- package/src/index.ts +5 -1
- package/src/locomotion.ts +430 -3
- package/src/model.ts +65 -1
- package/src/state-machine.ts +201 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { easeInOut } from "./math";
|
|
2
|
+
const FPS = 30;
|
|
3
|
+
export class AnimationStateMachine {
|
|
4
|
+
constructor(model, states, transitions, options) {
|
|
5
|
+
/** Outgoing state during a crossfade — keeps playing while it fades. */
|
|
6
|
+
this.fading = null;
|
|
7
|
+
// Scratch: per-slot entry objects are reused so downstream caches (blend
|
|
8
|
+
// cursors, clip-event trackers) keyed on entry identity stay warm.
|
|
9
|
+
this.merged = [];
|
|
10
|
+
this.inScratch = [{ name: "", time: 0, weight: 1 }];
|
|
11
|
+
this.outScratch = [{ name: "", time: 0, weight: 1 }];
|
|
12
|
+
this.model = model;
|
|
13
|
+
this.states = states;
|
|
14
|
+
this.transitions = transitions;
|
|
15
|
+
this.defaultFade = options.defaultFade ?? 0.25;
|
|
16
|
+
const def = states[options.initial];
|
|
17
|
+
if (!def)
|
|
18
|
+
throw new Error(`Unknown initial state "${options.initial}"`);
|
|
19
|
+
this.current = { name: options.initial, def, time: 0 };
|
|
20
|
+
def.onEnter?.(null);
|
|
21
|
+
}
|
|
22
|
+
get state() {
|
|
23
|
+
return this.current.name;
|
|
24
|
+
}
|
|
25
|
+
/** Seconds the current state has been active. */
|
|
26
|
+
get stateTime() {
|
|
27
|
+
return this.current.time;
|
|
28
|
+
}
|
|
29
|
+
/** Force a transition now, regardless of the transition table. */
|
|
30
|
+
go(to, fade) {
|
|
31
|
+
this.begin(to, fade ?? this.defaultFade);
|
|
32
|
+
}
|
|
33
|
+
update(dt) {
|
|
34
|
+
this.current.time += dt;
|
|
35
|
+
// Transitions are not interruptible mid-fade (go() still is).
|
|
36
|
+
if (this.fading === null) {
|
|
37
|
+
for (const t of this.transitions) {
|
|
38
|
+
if (t.to === this.current.name)
|
|
39
|
+
continue;
|
|
40
|
+
if (t.from !== "*" && t.from !== this.current.name)
|
|
41
|
+
continue;
|
|
42
|
+
if (!this.transitionReady(t))
|
|
43
|
+
continue;
|
|
44
|
+
this.begin(t.to, t.fade ?? this.defaultFade);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const inEntries = this.produce(this.current, dt, this.inScratch);
|
|
49
|
+
if (this.fading !== null) {
|
|
50
|
+
const f = this.fading;
|
|
51
|
+
f.elapsed += dt;
|
|
52
|
+
if (f.elapsed >= f.duration) {
|
|
53
|
+
this.fading = null;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
f.state.time += dt;
|
|
57
|
+
const outEntries = this.produce(f.state, dt, this.outScratch);
|
|
58
|
+
const w = easeInOut(f.elapsed / f.duration);
|
|
59
|
+
this.apply(outEntries, 1 - w, inEntries, w);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
this.apply(inEntries, 1, null, 0);
|
|
64
|
+
}
|
|
65
|
+
transitionReady(t) {
|
|
66
|
+
const def = this.current.def;
|
|
67
|
+
if (t.exitTime !== undefined) {
|
|
68
|
+
if (this.current.time < t.exitTime)
|
|
69
|
+
return false;
|
|
70
|
+
return t.when ? t.when() : true;
|
|
71
|
+
}
|
|
72
|
+
if (t.when)
|
|
73
|
+
return t.when();
|
|
74
|
+
// Unconditional, no exitTime: on a non-looping clip state this means
|
|
75
|
+
// "when the clip is about to end" (start the fade so it lands at the end).
|
|
76
|
+
if (def.clip && def.loop === false) {
|
|
77
|
+
const dur = this.clipDuration(def.clip) / (def.speed ?? 1);
|
|
78
|
+
return this.current.time >= Math.max(0, dur - (t.fade ?? this.defaultFade));
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
begin(to, fade) {
|
|
83
|
+
const def = this.states[to];
|
|
84
|
+
if (!def)
|
|
85
|
+
throw new Error(`Unknown state "${to}"`);
|
|
86
|
+
this.current.def.onExit?.(to);
|
|
87
|
+
// go() during an existing fade drops the older outgoing state (v1: no
|
|
88
|
+
// three-way mixes) — the current state becomes the outgoing one.
|
|
89
|
+
this.fading = fade > 0 ? { state: this.current, elapsed: 0, duration: fade } : null;
|
|
90
|
+
const from = this.current.name;
|
|
91
|
+
this.current = { name: to, def, time: 0 };
|
|
92
|
+
def.onEnter?.(from);
|
|
93
|
+
}
|
|
94
|
+
/** This frame's entries for a state: clip states own a one-entry pose;
|
|
95
|
+
* delegate states produce their own. Returns null for "no pose". */
|
|
96
|
+
produce(state, dt, scratch) {
|
|
97
|
+
const def = state.def;
|
|
98
|
+
if (def.entries)
|
|
99
|
+
return def.entries(dt);
|
|
100
|
+
if (!def.clip)
|
|
101
|
+
return null;
|
|
102
|
+
const dur = this.clipDuration(def.clip);
|
|
103
|
+
let t = state.time * (def.speed ?? 1);
|
|
104
|
+
if (dur > 0)
|
|
105
|
+
t = def.loop === false ? Math.min(t, dur) : t % dur;
|
|
106
|
+
scratch[0].name = def.clip;
|
|
107
|
+
scratch[0].time = t;
|
|
108
|
+
scratch[0].weight = 1;
|
|
109
|
+
return scratch;
|
|
110
|
+
}
|
|
111
|
+
/** Merge up to two entry lists scaled by their group weights into the stable
|
|
112
|
+
* scratch array and hand it to the model. */
|
|
113
|
+
apply(a, wa, b, wb) {
|
|
114
|
+
let n = 0;
|
|
115
|
+
const put = (src, scale) => {
|
|
116
|
+
if (src === null || scale <= 0)
|
|
117
|
+
return;
|
|
118
|
+
for (const e of src) {
|
|
119
|
+
if (!(e.weight > 1e-6))
|
|
120
|
+
continue;
|
|
121
|
+
let slot = this.merged[n];
|
|
122
|
+
if (!slot) {
|
|
123
|
+
slot = { name: "", time: 0, weight: 0 };
|
|
124
|
+
this.merged[n] = slot;
|
|
125
|
+
}
|
|
126
|
+
slot.name = e.name;
|
|
127
|
+
slot.time = e.time;
|
|
128
|
+
slot.weight = e.weight * scale;
|
|
129
|
+
n++;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
// Order: incoming FIRST so slot identities stay stable for a given state
|
|
133
|
+
// across the fade's start/end (cursor + event caches key on the objects).
|
|
134
|
+
put(b, wb);
|
|
135
|
+
put(a, wa);
|
|
136
|
+
for (let i = n; i < this.merged.length; i++)
|
|
137
|
+
this.merged[i].weight = 0;
|
|
138
|
+
if (n > 0)
|
|
139
|
+
this.model.setBlendPose(this.merged);
|
|
140
|
+
}
|
|
141
|
+
clipDuration(name) {
|
|
142
|
+
const frames = this.model.getClip(name)?.frameCount ?? 0;
|
|
143
|
+
return frames > 1 ? (frames - 1) / FPS : 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/package.json
CHANGED
package/src/camera.ts
CHANGED
|
@@ -138,8 +138,19 @@ export class Camera {
|
|
|
138
138
|
if (this.vmdDriven) {
|
|
139
139
|
const eye = this.vmdEye()
|
|
140
140
|
const s = this._quatScratch
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// View = Rᵀ · T(−eye), built straight from the quaternion basis instead of
|
|
142
|
+
// lookAt(eye, target): same matrix for a normal d<0 shot, but position-baked
|
|
143
|
+
// tracks store distance = 0 on every frame (eye == target), which degenerates
|
|
144
|
+
// lookAt's normalize to an all-zero basis. The orientation never depended on
|
|
145
|
+
// the eye→target line anyway — it IS the euler rotation.
|
|
146
|
+
const o = this._viewMat.values
|
|
147
|
+
o[0] = s[0]; o[1] = s[4]; o[2] = s[8]; o[3] = 0
|
|
148
|
+
o[4] = s[1]; o[5] = s[5]; o[6] = s[9]; o[7] = 0
|
|
149
|
+
o[8] = s[2]; o[9] = s[6]; o[10] = s[10]; o[11] = 0
|
|
150
|
+
o[12] = -(s[0] * eye.x + s[1] * eye.y + s[2] * eye.z)
|
|
151
|
+
o[13] = -(s[4] * eye.x + s[5] * eye.y + s[6] * eye.z)
|
|
152
|
+
o[14] = -(s[8] * eye.x + s[9] * eye.y + s[10] * eye.z)
|
|
153
|
+
o[15] = 1
|
|
143
154
|
return this._viewMat
|
|
144
155
|
}
|
|
145
156
|
const eye = this.getPosition()
|
package/src/engine.ts
CHANGED
|
@@ -809,6 +809,9 @@ export class Engine {
|
|
|
809
809
|
// Camera target binding (Babylon/Three style: camera follows model)
|
|
810
810
|
private cameraTargetModel: Model | null = null
|
|
811
811
|
private cameraTargetBoneName = "全ての親"
|
|
812
|
+
private cameraFollowSmoothing = 0
|
|
813
|
+
private cameraFollowSeeded = false
|
|
814
|
+
private readonly cameraFollowPos = new Vec3(0, 0, 0)
|
|
812
815
|
private cameraTargetOffset: Vec3 = new Vec3(0, 0, 0)
|
|
813
816
|
|
|
814
817
|
private lastFrameTime = performance.now()
|
|
@@ -2318,6 +2321,9 @@ export class Engine {
|
|
|
2318
2321
|
if (!this.multisampleTexture || this.canvas.width !== width || this.canvas.height !== height) {
|
|
2319
2322
|
this.canvas.width = width
|
|
2320
2323
|
this.canvas.height = height
|
|
2324
|
+
// bgResolution() reads the canvas size from the composite uniforms —
|
|
2325
|
+
// refresh on resize or effects aspect-correct against the stale size.
|
|
2326
|
+
if (this.compositeUniformBuffer) this.writeCompositeViewUniforms()
|
|
2321
2327
|
|
|
2322
2328
|
this.multisampleTexture = this.device.createTexture({
|
|
2323
2329
|
label: "multisample HDR render target",
|
|
@@ -2758,7 +2764,7 @@ export class Engine {
|
|
|
2758
2764
|
}
|
|
2759
2765
|
|
|
2760
2766
|
/** Souls-style follow cam: orbit center tracks a model bone each frame. Shorthand for setCameraTarget(model, boneName, offset). */
|
|
2761
|
-
setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3): void {
|
|
2767
|
+
setCameraFollow(model: Model | null, boneName?: string, offset?: Vec3, smoothing?: number): void {
|
|
2762
2768
|
if (model === null) {
|
|
2763
2769
|
this.cameraTargetModel = null
|
|
2764
2770
|
return
|
|
@@ -2768,6 +2774,10 @@ export class Engine {
|
|
|
2768
2774
|
this.cameraTargetOffset.x = offset?.x ?? 0
|
|
2769
2775
|
this.cameraTargetOffset.y = offset?.y ?? 0
|
|
2770
2776
|
this.cameraTargetOffset.z = offset?.z ?? 0
|
|
2777
|
+
// Handheld feel: seconds for the camera to close ~63% of the gap to the
|
|
2778
|
+
// bone (exponential, frame-rate independent). 0 = rigid instant follow.
|
|
2779
|
+
this.cameraFollowSmoothing = Math.max(0, smoothing ?? 0)
|
|
2780
|
+
this.cameraFollowSeeded = false
|
|
2771
2781
|
}
|
|
2772
2782
|
|
|
2773
2783
|
// ── VMD camera track ──
|
|
@@ -2792,6 +2802,16 @@ export class Engine {
|
|
|
2792
2802
|
/** Turn the loaded camera VMD on/off (falls back to orbit when off). No-op if none loaded. */
|
|
2793
2803
|
setCameraVmdEnabled(enabled: boolean): void {
|
|
2794
2804
|
this.camera.setVmdDriven(enabled && this.cameraAnimation !== null)
|
|
2805
|
+
if (!enabled && this.cameraTargetModel) {
|
|
2806
|
+
// Follow resumes with a clean snap to bone + configured offset — one
|
|
2807
|
+
// predictable cut to the scene's framing, no easing from the shot.
|
|
2808
|
+
this.cameraFollowSeeded = false
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
/** True while the orbit target is riding a model bone (setCameraFollow). */
|
|
2813
|
+
isCameraFollowing(): boolean {
|
|
2814
|
+
return this.cameraTargetModel !== null
|
|
2795
2815
|
}
|
|
2796
2816
|
|
|
2797
2817
|
/** True while the loaded camera VMD is actively driving the shot. */
|
|
@@ -2823,6 +2843,11 @@ export class Engine {
|
|
|
2823
2843
|
return first ?? 0
|
|
2824
2844
|
}
|
|
2825
2845
|
|
|
2846
|
+
/** Current orbit eye position (spherical coords resolved to a point). */
|
|
2847
|
+
getCameraPosition(): Vec3 {
|
|
2848
|
+
return this.camera.getPosition()
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2826
2851
|
getCameraDistance(): number {
|
|
2827
2852
|
return this.camera.radius
|
|
2828
2853
|
}
|
|
@@ -4682,9 +4707,28 @@ export class Engine {
|
|
|
4682
4707
|
py += pos.y
|
|
4683
4708
|
pz += pos.z
|
|
4684
4709
|
}
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4710
|
+
px += this.cameraTargetOffset.x
|
|
4711
|
+
py += this.cameraTargetOffset.y
|
|
4712
|
+
pz += this.cameraTargetOffset.z
|
|
4713
|
+
const tau = this.cameraFollowSmoothing
|
|
4714
|
+
if (tau > 0 && this.cameraFollowSeeded) {
|
|
4715
|
+
// Exponential lag toward the bone: the handheld-camera feel. The
|
|
4716
|
+
// orbit pivot trails the target and eases in, never snapping.
|
|
4717
|
+
const k = 1 - Math.exp(-deltaTime / tau)
|
|
4718
|
+
const f = this.cameraFollowPos
|
|
4719
|
+
f.x += (px - f.x) * k
|
|
4720
|
+
f.y += (py - f.y) * k
|
|
4721
|
+
f.z += (pz - f.z) * k
|
|
4722
|
+
this.camera.target.x = f.x
|
|
4723
|
+
this.camera.target.y = f.y
|
|
4724
|
+
this.camera.target.z = f.z
|
|
4725
|
+
} else {
|
|
4726
|
+
this.cameraFollowPos.setXYZ(px, py, pz)
|
|
4727
|
+
this.cameraFollowSeeded = true
|
|
4728
|
+
this.camera.target.x = px
|
|
4729
|
+
this.camera.target.y = py
|
|
4730
|
+
this.camera.target.z = pz
|
|
4731
|
+
}
|
|
4688
4732
|
}
|
|
4689
4733
|
}
|
|
4690
4734
|
|
package/src/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export { BODY_GRAPH } from "./graph/presets/body"
|
|
|
52
52
|
export { STOCKINGS_GRAPH } from "./graph/presets/stockings"
|
|
53
53
|
export { EYE_GRAPH } from "./graph/presets/eye"
|
|
54
54
|
export { FACE_GRAPH } from "./graph/presets/face"
|
|
55
|
-
export { Model } from "./model"
|
|
55
|
+
export { Model, type ClipEventInfo } from "./model"
|
|
56
56
|
export { Vec3, Quat, Mat4, easeInOut, type EulerOrder } from "./math"
|
|
57
57
|
export type {
|
|
58
58
|
AnimationClip,
|
|
@@ -71,7 +71,11 @@ export {
|
|
|
71
71
|
type LocomotionOptions,
|
|
72
72
|
type LocomotionPose,
|
|
73
73
|
type StrafeClipEntry,
|
|
74
|
+
type TurnClipEntry,
|
|
75
|
+
type RunTurnClipEntry,
|
|
76
|
+
type StopClipEntry,
|
|
74
77
|
} from "./locomotion"
|
|
78
|
+
export { AnimationStateMachine, type AnimStateDef, type AnimTransitionDef, type StateMachineOptions } from "./state-machine"
|
|
75
79
|
export {
|
|
76
80
|
FPS,
|
|
77
81
|
bezierInterpolate,
|