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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.30.2",
3
+ "version": "0.31.2",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
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
- const t = this._vmdTarget
142
- Mat4.lookAtInto(this._viewMat.values, eye.x, eye.y, eye.z, t.x, t.y, t.z, s[4], s[5], s[6])
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
- this.camera.target.x = px + this.cameraTargetOffset.x
4686
- this.camera.target.y = py + this.cameraTargetOffset.y
4687
- this.camera.target.z = pz + this.cameraTargetOffset.z
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,