reze-engine 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/animation.ts CHANGED
@@ -61,6 +61,17 @@ export interface AnimationProgress {
61
61
 
62
62
  export const FPS = 30
63
63
 
64
+ /** One weighted contribution to a blended pose (see Model.setBlendPose). */
65
+ export interface BlendEntry {
66
+ /** Name of a clip previously loaded with loadVmd/loadClip. */
67
+ name: string
68
+ /** Clip-local time in seconds. The caller owns the clock — wrap or clamp before passing. */
69
+ time: number
70
+ /** Relative weight >= 0. Entries are normalized over their sum; a sum below 1 is
71
+ * NOT renormalized — the remainder blends toward the rest pose. */
72
+ weight: number
73
+ }
74
+
64
75
  interface QueuedAnimationRequest {
65
76
  name: string
66
77
  priority: number
@@ -152,6 +163,21 @@ export class AnimationState {
152
163
  return true
153
164
  }
154
165
 
166
+ /** Make `name` current at frame 0 and play it, bypassing priority arbitration.
167
+ * Used by crossfadeTo, which owns the transition and must not be queued behind
168
+ * the clip it is fading away from. */
169
+ forcePlay(name: string, loop: boolean): boolean {
170
+ if (!this.animations.has(name)) return false
171
+ this.currentAnimationName = name
172
+ this.currentFrame = 0
173
+ this.currentPriority = 0
174
+ this.currentLoop = loop
175
+ this.isPlaying = true
176
+ this.isPaused = false
177
+ this.nextAnimation = null
178
+ return true
179
+ }
180
+
155
181
  update(deltaTime: number): { ended: boolean; animationName: string | null } {
156
182
  if (!this.isPlaying || this.isPaused || this.currentAnimationName === null) {
157
183
  return { ended: false, animationName: this.currentAnimationName }
package/src/engine.ts CHANGED
@@ -3628,14 +3628,40 @@ export class Engine {
3628
3628
  * frustum onto an otherwise empty floor. */
3629
3629
  private shadowMapPopulated = true
3630
3630
  private shadowLightVPDirty = true
3631
+ // Last shadow-volume center, to skip recomputes while nothing moves.
3632
+ private readonly shadowCenter = new Vec3(0, 11, 0)
3633
+
3631
3634
  private updateShadowLightVP() {
3632
- if (!this.shadowLightVPDirty) return
3635
+ // The 64×64-unit volume follows the camera target so a character carried far
3636
+ // from the origin by code-driven root motion stays inside the lit frustum.
3637
+ const t = this.camera.target
3638
+ const moved =
3639
+ Math.abs(t.x - this.shadowCenter.x) > 1e-3 ||
3640
+ Math.abs(t.y - this.shadowCenter.y) > 1e-3 ||
3641
+ Math.abs(t.z - this.shadowCenter.z) > 1e-3
3642
+ if (!this.shadowLightVPDirty && !moved) return
3633
3643
  this.shadowLightVPDirty = false
3644
+ this.shadowCenter.setXYZ(t.x, t.y, t.z)
3645
+
3634
3646
  const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z)
3635
3647
  dir.normalize()
3636
- const target = new Vec3(0, 11, 0)
3637
- const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
3638
3648
  const up = Math.abs(dir.y) > 0.99 ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0)
3649
+
3650
+ // Snap the center to shadow-map texels in the light's right/up plane so the
3651
+ // moving volume doesn't shimmer the shadow edges while running.
3652
+ const right = Vec3.crossInto(up, dir, new Vec3(0, 0, 0)).normalize()
3653
+ const upv = Vec3.crossInto(dir, right, new Vec3(0, 0, 0))
3654
+ const texel = 64 / Engine.SHADOW_MAP_SIZE
3655
+ const tr = Math.round(t.dot(right) / texel) * texel
3656
+ const tu = Math.round(t.dot(upv) / texel) * texel
3657
+ const td = t.dot(dir)
3658
+ const target = new Vec3(
3659
+ right.x * tr + upv.x * tu + dir.x * td,
3660
+ right.y * tr + upv.y * tu + dir.y * td,
3661
+ right.z * tr + upv.z * tu + dir.z * td
3662
+ )
3663
+
3664
+ const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72)
3639
3665
  const view = Mat4.lookAt(eye, target, up)
3640
3666
  const proj = Mat4.orthographicLh(-32, 32, -32, 32, 1, 140)
3641
3667
  const vp = proj.multiply(view)
@@ -4589,12 +4615,24 @@ export class Engine {
4589
4615
  if (hasModels) {
4590
4616
  this.updateInstances(deltaTime)
4591
4617
  this.updateSkinMatrices()
4592
- // Update camera target from bound model (bone not found 0,0,0 + offset)
4618
+ // Update camera target from bound model. Bone world matrices are model-space,
4619
+ // so compose the scene placement (setModelTransform) — otherwise the camera
4620
+ // ignores a moved/rotated model (code-driven root motion). Bone not found →
4621
+ // follow the model root itself.
4593
4622
  if (this.cameraTargetModel) {
4594
- const pos = this.cameraTargetModel.getBoneWorldPosition(this.cameraTargetBoneName)
4595
- const px = pos?.x ?? 0
4596
- const py = pos?.y ?? 0
4597
- const pz = pos?.z ?? 0
4623
+ const m = this.cameraTargetModel
4624
+ const pos = m.getBoneWorldPosition(this.cameraTargetBoneName)
4625
+ let px = m.position.x
4626
+ let py = m.position.y
4627
+ let pz = m.position.z
4628
+ if (pos) {
4629
+ const s = m.scale
4630
+ pos.setXYZ(pos.x * s, pos.y * s, pos.z * s)
4631
+ Quat.rotateVecInto(m.rotation, pos, pos)
4632
+ px += pos.x
4633
+ py += pos.y
4634
+ pz += pos.z
4635
+ }
4598
4636
  this.camera.target.x = px + this.cameraTargetOffset.x
4599
4637
  this.camera.target.y = py + this.cameraTargetOffset.y
4600
4638
  this.camera.target.z = pz + this.cameraTargetOffset.z
package/src/index.ts CHANGED
@@ -58,12 +58,19 @@ export type {
58
58
  AnimationClip,
59
59
  AnimationPlayOptions,
60
60
  AnimationProgress,
61
+ BlendEntry,
61
62
  BoneKeyframe,
62
63
  IkKeyframe,
63
64
  MorphKeyframe,
64
65
  BoneInterpolation,
65
66
  ControlPoint,
66
67
  } from "./animation"
68
+ export {
69
+ LocomotionController,
70
+ type LocomotionClips,
71
+ type LocomotionOptions,
72
+ type LocomotionPose,
73
+ } from "./locomotion"
67
74
  export {
68
75
  FPS,
69
76
  bezierInterpolate,
@@ -71,5 +78,7 @@ export {
71
78
  rawInterpolationToBoneInterpolation,
72
79
  } from "./animation"
73
80
  export { VMDLoader, type CameraKeyframe, type IkFrame } from "./vmd-loader"
81
+ export { VMDWriter } from "./vmd-writer"
82
+ export { PmxLoader } from "./pmx-loader"
74
83
  export { CameraAnimation, type CameraPose } from "./camera-animation"
75
84
  export { RezePhysics } from "./physics"
@@ -0,0 +1,254 @@
1
+ // Game-style locomotion over the blend primitive: idle ↔ run ↔ sprint mixed by a
2
+ // smoothed speed level, yaw eased toward the input heading, root motion integrated
3
+ // in code (the clips are in-place). Zero renderer coupling — the controller only
4
+ // talks to Model.setBlendPose and returns the root transform for the host to apply
5
+ // via engine.setModelTransform.
6
+
7
+ import { Model } from "./model"
8
+ import { FPS, type BlendEntry } from "./animation"
9
+ import { Quat, Vec3 } from "./math"
10
+
11
+ export interface LocomotionClips {
12
+ /** Clip names previously loaded on the model (loadVmd/loadClip). */
13
+ idle: string
14
+ run: string
15
+ sprint?: string
16
+ }
17
+
18
+ export interface LocomotionOptions {
19
+ /** Ground speed in MMD units/second at full run (default 67 — measured from the Unity
20
+ * locomotion pack's root motion before the in-place strip; ≈5.4 m/s at MMD scale).
21
+ * Match this to the clips or the feet slide. */
22
+ runSpeed?: number
23
+ /** Ground speed at full sprint (default 92, measured the same way; ≈7.4 m/s). */
24
+ sprintSpeed?: number
25
+ /** Response rate of the idle↔run↔sprint blend, in speed-levels/second (default 5). */
26
+ speedResponse?: number
27
+ /** Yaw easing rate toward the input heading, 1/second (default 10). */
28
+ turnResponse?: number
29
+ /** Heading error (radians) beyond which the character pivots strictly in place —
30
+ * no translation until the body is back within this cone (default PI/4). */
31
+ turnInPlaceThreshold?: number
32
+ /** Tank-mode steering rate, radians/second (default 2.5 ≈ 143°/s). */
33
+ steerRate?: number
34
+ /** Backpedal speed as a fraction of run speed in tank mode (default 0.5). */
35
+ backpedalScale?: number
36
+ /** World yaw the model faces at rotation 0. MMD models rest facing -Z, so facing a
37
+ * heading of `yaw` needs rotationY = yaw + PI — the default. */
38
+ yawOffset?: number
39
+ }
40
+
41
+ /** The integrated root transform for this frame. `position` and `rotation` are
42
+ * REUSED instances owned by the controller — apply them immediately (they feed
43
+ * straight into engine.setModelTransform), don't store them. */
44
+ export interface LocomotionPose {
45
+ position: Vec3
46
+ /** Heading in radians: 0 = +Z, increasing toward +X. */
47
+ yaw: number
48
+ /** rotationY quat including yawOffset, ready for setModelTransform. */
49
+ rotation: Quat
50
+ /** Smoothed speed level: 0 idle, 1 run, 2 sprint. */
51
+ speedLevel: number
52
+ }
53
+
54
+ const TWO_PI = Math.PI * 2
55
+
56
+ function wrapAngle(a: number): number {
57
+ while (a > Math.PI) a -= TWO_PI
58
+ while (a < -Math.PI) a += TWO_PI
59
+ return a
60
+ }
61
+
62
+ export class LocomotionController {
63
+ private readonly model: Model
64
+ private readonly clips: LocomotionClips
65
+ private readonly runSpeed: number
66
+ private readonly sprintSpeed: number
67
+ private readonly speedResponse: number
68
+ private readonly turnResponse: number
69
+ private readonly cosTurnThreshold: number
70
+ private readonly yawOffset: number
71
+
72
+ private inputX = 0
73
+ private inputY = 0
74
+ private inputSprint = false
75
+ // Tank mode: forward/steer relative to the CURRENT facing, camera never involved.
76
+ private tankMode = false
77
+ private inputForward = 0
78
+ private inputSteer = 0
79
+ private readonly steerRate: number
80
+ private readonly backpedalScale: number
81
+
82
+ private speedLevel = 0
83
+ private yaw = 0
84
+ // Movement direction = the INPUT heading, not the body yaw. The body turns
85
+ // cosmetically toward it; translating along the (sweeping) body yaw instead
86
+ // would nudge the character through the forward arc on every L↔R reversal.
87
+ private dirX = 0
88
+ private dirZ = 1
89
+ private readonly position = new Vec3(0, 0, 0)
90
+ private readonly rotation = new Quat(0, 0, 0, 1)
91
+ private idleTime = 0
92
+ private gaitPhase = 0 // 0..1, shared by run and sprint so legs stay aligned across the blend
93
+
94
+ private readonly entries: BlendEntry[]
95
+ private readonly pose: LocomotionPose
96
+
97
+ constructor(model: Model, clips: LocomotionClips, options?: LocomotionOptions) {
98
+ this.model = model
99
+ this.clips = clips
100
+ this.runSpeed = options?.runSpeed ?? 67
101
+ this.sprintSpeed = options?.sprintSpeed ?? 92
102
+ this.speedResponse = options?.speedResponse ?? 5
103
+ this.turnResponse = options?.turnResponse ?? 10
104
+ this.cosTurnThreshold = Math.cos(options?.turnInPlaceThreshold ?? Math.PI / 4)
105
+ this.steerRate = options?.steerRate ?? 2.5
106
+ this.backpedalScale = options?.backpedalScale ?? 0.5
107
+ this.yawOffset = options?.yawOffset ?? Math.PI
108
+ this.entries = [
109
+ { name: clips.idle, time: 0, weight: 1 },
110
+ { name: clips.run, time: 0, weight: 0 },
111
+ { name: clips.sprint ?? clips.run, time: 0, weight: 0 },
112
+ ]
113
+ this.pose = { position: this.position, yaw: 0, rotation: this.rotation, speedLevel: 0 }
114
+ }
115
+
116
+ /** World-vector move input: x = +right (+X), y = +forward (+Z), magnitude clamped
117
+ * to 1. The character turns toward the vector, then runs along it. Call whenever
118
+ * input changes — the value holds between calls. */
119
+ setMove(x: number, y: number, sprint = false): void {
120
+ const m = Math.hypot(x, y)
121
+ if (m > 1) {
122
+ x /= m
123
+ y /= m
124
+ }
125
+ this.tankMode = false
126
+ this.inputX = x
127
+ this.inputY = y
128
+ this.inputSprint = sprint
129
+ }
130
+
131
+ /** Tank-style input relative to the CURRENT facing, camera-independent and fully
132
+ * deterministic: `steer` (−1..1, + = her right) rotates at steerRate whether
133
+ * standing or moving, `forward` (+1 run ahead — curving while steering — or −1
134
+ * backpedal at backpedalScale). Holds between calls, like setMove. */
135
+ setDrive(forward: number, steer: number, sprint = false): void {
136
+ this.tankMode = true
137
+ this.inputForward = Math.max(-1, Math.min(1, forward))
138
+ this.inputSteer = Math.max(-1, Math.min(1, steer))
139
+ this.inputSprint = sprint
140
+ }
141
+
142
+ /** Place the character (initial spawn or respawn). */
143
+ teleport(x: number, y: number, z: number, yaw = 0): void {
144
+ this.position.setXYZ(x, y, z)
145
+ this.yaw = yaw
146
+ }
147
+
148
+ getPosition(): Vec3 {
149
+ return this.position
150
+ }
151
+
152
+ /** Stop driving the model's pose (the blend is cleared; the single-clip player resumes). */
153
+ detach(): void {
154
+ this.model.clearBlendPose()
155
+ }
156
+
157
+ private clipDuration(name: string): number {
158
+ const frames = this.model.getClip(name)?.frameCount ?? 0
159
+ return frames > 0 ? frames / FPS : 1
160
+ }
161
+
162
+ /** Advance one frame: integrates yaw + position, updates the clip clocks, hands the
163
+ * weighted pose to the model, and returns the root transform to apply. */
164
+ update(dt: number): LocomotionPose {
165
+ if (dt > 0.1) dt = 0.1 // tab-switch guard: never integrate a huge step
166
+
167
+ const hasSprint = this.clips.sprint !== undefined
168
+ let moving: boolean
169
+ let align = 1
170
+ let speedScale = 1
171
+
172
+ if (this.tankMode) {
173
+ // Steering rotates the facing directly — standing or moving — and the travel
174
+ // direction IS the facing, so no pivot gate and no drift by construction.
175
+ this.yaw = wrapAngle(this.yaw + this.inputSteer * this.steerRate * dt)
176
+ const fwd = this.inputForward
177
+ moving = Math.abs(fwd) > 0.05
178
+ if (moving) {
179
+ const back = fwd < 0
180
+ this.dirX = Math.sin(this.yaw) * Math.sign(fwd)
181
+ this.dirZ = Math.cos(this.yaw) * Math.sign(fwd)
182
+ speedScale = Math.abs(fwd) * (back ? this.backpedalScale : 1)
183
+ }
184
+ } else {
185
+ const m = Math.hypot(this.inputX, this.inputY)
186
+ moving = m > 0.05
187
+ // Yaw eases toward the input heading only while there is one. Turns are strictly
188
+ // in place: zero translation outside the threshold cone, ramping smoothly to full
189
+ // speed as the body aligns — so direction reversals (L-R-L) cannot drift.
190
+ if (moving) {
191
+ const desired = Math.atan2(this.inputX, this.inputY)
192
+ const err = wrapAngle(desired - this.yaw)
193
+ this.yaw = wrapAngle(this.yaw + err * Math.min(1, this.turnResponse * dt))
194
+ align = Math.max(0, (Math.cos(err) - this.cosTurnThreshold) / (1 - this.cosTurnThreshold))
195
+ this.dirX = this.inputX / m
196
+ this.dirZ = this.inputY / m
197
+ }
198
+ }
199
+
200
+ // Speed level ramps linearly toward the target; the pose blend follows it.
201
+ // (No sprint while backpedaling.)
202
+ const sprinting = this.inputSprint && hasSprint && !(this.tankMode && this.inputForward < 0)
203
+ const target = moving ? (sprinting ? 2 : 1) : 0
204
+ const maxStep = this.speedResponse * dt
205
+ const d = target - this.speedLevel
206
+ this.speedLevel += Math.abs(d) <= maxStep ? d : Math.sign(d) * maxStep
207
+
208
+ // Root motion along the travel direction. In-place clips carry no horizontal root.
209
+ const speed =
210
+ (this.speedLevel <= 1
211
+ ? this.runSpeed * this.speedLevel
212
+ : this.runSpeed + (this.sprintSpeed - this.runSpeed) * (this.speedLevel - 1)) *
213
+ align *
214
+ speedScale
215
+ this.position.x += this.dirX * speed * dt
216
+ this.position.z += this.dirZ * speed * dt
217
+
218
+ // Clocks: idle free-runs; run/sprint share one normalized gait phase so a
219
+ // mid-blend stride stays on the same feet.
220
+ const idleDur = this.clipDuration(this.clips.idle)
221
+ const runDur = this.clipDuration(this.clips.run)
222
+ const sprintDur = hasSprint ? this.clipDuration(this.clips.sprint!) : runDur
223
+ this.idleTime = (this.idleTime + dt) % idleDur
224
+ const gaitDur = this.speedLevel <= 1 ? runDur : runDur + (sprintDur - runDur) * (this.speedLevel - 1)
225
+ this.gaitPhase = (this.gaitPhase + dt / gaitDur) % 1
226
+
227
+ // Weights along the 1D speed axis.
228
+ let wIdle: number, wRun: number, wSprint: number
229
+ if (this.speedLevel <= 1) {
230
+ wIdle = 1 - this.speedLevel
231
+ wRun = this.speedLevel
232
+ wSprint = 0
233
+ } else {
234
+ wIdle = 0
235
+ wRun = 2 - this.speedLevel
236
+ wSprint = this.speedLevel - 1
237
+ }
238
+
239
+ this.entries[0].time = this.idleTime
240
+ this.entries[0].weight = wIdle
241
+ this.entries[1].time = this.gaitPhase * runDur
242
+ this.entries[1].weight = wRun
243
+ this.entries[2].time = this.gaitPhase * sprintDur
244
+ this.entries[2].weight = wSprint
245
+ this.model.setBlendPose(this.entries)
246
+
247
+ const ry = this.yaw + this.yawOffset
248
+ const half = ry * 0.5
249
+ this.rotation.setXYZW(0, Math.sin(half), 0, Math.cos(half))
250
+ this.pose.yaw = this.yaw
251
+ this.pose.speedLevel = this.speedLevel
252
+ return this.pose
253
+ }
254
+ }