reze-engine 0.29.1 → 0.30.1

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/dist/model.js CHANGED
@@ -101,6 +101,11 @@ export class Model {
101
101
  this.boneTrackIndices = new Map();
102
102
  this.morphTrackIndices = new Map();
103
103
  this.lastAppliedClip = null;
104
+ // One-shot action layer: plays a clip ONCE over whatever else drives the pose
105
+ // (locomotion blend, a playing clip, a crossfade, or rest), with fade-in/out
106
+ // envelopes — the background keeps advancing and is what the fade-out returns to.
107
+ this.oneShot = null;
108
+ this.oneShotEntries = [];
104
109
  // Blended pose: declarative N-clip mix (setBlendPose) or a running crossfade.
105
110
  // Cursor caches are per clip so sampling several clips in one frame doesn't
106
111
  // thrash the single-clip caches above; WeakMap so removed clips can collect.
@@ -1018,6 +1023,7 @@ export class Model {
1018
1023
  }
1019
1024
  this.blendEntries = null;
1020
1025
  this.crossfade = null;
1026
+ this.oneShot = null;
1021
1027
  this.resetAllBones();
1022
1028
  this.resetAllMorphs();
1023
1029
  return this.animationState.play(name, options);
@@ -1025,6 +1031,7 @@ export class Model {
1025
1031
  show(name) {
1026
1032
  this.blendEntries = null;
1027
1033
  this.crossfade = null;
1034
+ this.oneShot = null;
1028
1035
  this.resetAllBones();
1029
1036
  this.resetAllMorphs();
1030
1037
  this.animationState.show(name);
@@ -1042,6 +1049,36 @@ export class Model {
1042
1049
  clearBlendPose() {
1043
1050
  this.blendEntries = null;
1044
1051
  }
1052
+ /** Play a clip ONCE over whatever currently drives the pose — the locomotion blend,
1053
+ * a playing clip, a crossfade, or rest. The background keeps advancing underneath;
1054
+ * fade-in ramps the one-shot over it and fade-out returns to whatever the
1055
+ * background is doing by then. Replaces any active one-shot immediately. */
1056
+ playOneShot(name, options) {
1057
+ const clip = this.animationState.getAnimationClip(name);
1058
+ if (!clip || clip.frameCount <= 0 || !Number.isFinite(clip.frameCount))
1059
+ return false;
1060
+ const duration = clip.frameCount / FPS;
1061
+ const fadeIn = Math.max(0, options?.fadeIn ?? 0.15);
1062
+ const fadeOut = Math.max(0, Math.min(options?.fadeOut ?? 0.25, duration));
1063
+ this.oneShot = { name, time: 0, duration, fadeIn, fadeOut, cancelW: 1, cancelling: false, onEnd: options?.onEnd ?? null };
1064
+ this.clipApplySuspended = false;
1065
+ return true;
1066
+ }
1067
+ /** Fade the active one-shot out early over `fadeOut` seconds (default 0.2). */
1068
+ cancelOneShot(fadeOut = 0.2) {
1069
+ if (!this.oneShot)
1070
+ return;
1071
+ if (fadeOut <= 0) {
1072
+ this.oneShot = null;
1073
+ return;
1074
+ }
1075
+ this.oneShot.cancelling = true;
1076
+ this.oneShot.fadeOut = fadeOut;
1077
+ }
1078
+ /** Name of the active one-shot, or null. */
1079
+ getOneShot() {
1080
+ return this.oneShot?.name ?? null;
1081
+ }
1045
1082
  /** Fade from the currently playing clip (or from the rest pose when nothing plays)
1046
1083
  * into `name` over `seconds`. The target starts at frame 0 and becomes the current
1047
1084
  * clip immediately — progress, looping and the camera clock report the target for
@@ -1050,6 +1087,7 @@ export class Model {
1050
1087
  if (!this.animationState.hasAnimation(name))
1051
1088
  return false;
1052
1089
  this.blendEntries = null;
1090
+ this.oneShot = null;
1053
1091
  this.clipApplySuspended = false;
1054
1092
  const fromName = this.animationState.getCurrentAnimation();
1055
1093
  const fromFrame = this.animationState.getCurrentFrame();
@@ -1077,6 +1115,7 @@ export class Model {
1077
1115
  stop() {
1078
1116
  this.blendEntries = null;
1079
1117
  this.crossfade = null;
1118
+ this.oneShot = null;
1080
1119
  this.animationState.stop();
1081
1120
  }
1082
1121
  // @deprecated Use model.stop()
@@ -1089,6 +1128,7 @@ export class Model {
1089
1128
  clearAnimation() {
1090
1129
  this.blendEntries = null;
1091
1130
  this.crossfade = null;
1131
+ this.oneShot = null;
1092
1132
  this.animationState.clear();
1093
1133
  }
1094
1134
  // Seek by absolute timeline seconds, not frame index.
@@ -1381,6 +1421,61 @@ export class Model {
1381
1421
  this.morphsDirty = true;
1382
1422
  }
1383
1423
  }
1424
+ /** One one-shot step: weight envelope from fade-in/out (easeInOut-shaped), the
1425
+ * background entries scaled by 1-w underneath, the one-shot clip at w on top. */
1426
+ applyOneShot(deltaTime) {
1427
+ const os = this.oneShot;
1428
+ if (os === null)
1429
+ return;
1430
+ os.time += deltaTime;
1431
+ // Envelope: min of the fade-in ramp and the fade-out ramp (or the cancel ramp).
1432
+ const wIn = os.fadeIn > 0 ? Math.min(1, os.time / os.fadeIn) : 1;
1433
+ let wOut = 1;
1434
+ if (os.cancelling) {
1435
+ os.cancelW -= deltaTime / os.fadeOut;
1436
+ wOut = Math.max(0, os.cancelW);
1437
+ }
1438
+ else if (os.fadeOut > 0) {
1439
+ wOut = Math.max(0, Math.min(1, (os.duration - os.time) / os.fadeOut));
1440
+ }
1441
+ const w = easeInOut(Math.min(wIn, wOut));
1442
+ // Background entries at (1 - w): the still-advancing blend, the current clip, or
1443
+ // nothing (rest fill). Copied into a pooled array — never scale caller-owned weights.
1444
+ const pool = this.oneShotEntries;
1445
+ let n = 0;
1446
+ const put = (name, time, weight) => {
1447
+ if (pool.length <= n)
1448
+ pool.push({ name: "", time: 0, weight: 0 });
1449
+ const e = pool[n++];
1450
+ e.name = name;
1451
+ e.time = time;
1452
+ e.weight = weight;
1453
+ };
1454
+ const bg = 1 - w;
1455
+ if (bg > 1e-6) {
1456
+ if (this.blendEntries !== null && this.blendEntries.length > 0) {
1457
+ for (const e of this.blendEntries)
1458
+ put(e.name, e.time, e.weight * bg);
1459
+ }
1460
+ else {
1461
+ const clip = this.animationState.getCurrentClip();
1462
+ const name = this.animationState.getCurrentAnimation();
1463
+ if (clip !== null && name !== null && name !== os.name) {
1464
+ put(name, this.animationState.getCurrentFrame() / FPS, bg);
1465
+ }
1466
+ // else: rest pose fills the remainder inside applyBlendedPose
1467
+ }
1468
+ }
1469
+ put(os.name, Math.min(os.time, os.duration), w);
1470
+ for (let i = n; i < pool.length; i++)
1471
+ pool[i].weight = 0;
1472
+ this.applyBlendedPose(pool);
1473
+ if (os.time >= os.duration || (os.cancelling && os.cancelW <= 0)) {
1474
+ const onEnd = os.onEnd;
1475
+ this.oneShot = null;
1476
+ onEnd?.();
1477
+ }
1478
+ }
1384
1479
  /** One crossfade step: advance the outgoing clock (the target's clock lives in
1385
1480
  * animationState, already ticked by update), shape the weight with easeInOut,
1386
1481
  * and hand both to the blend sampler. Holds in place while paused. */
@@ -1433,7 +1528,10 @@ export class Model {
1433
1528
  const tweensChangedMorphs = this.updateTweens();
1434
1529
  this.animationState.update(deltaTime);
1435
1530
  if (!this.clipApplySuspended) {
1436
- if (this.blendEntries !== null && this.blendEntries.length > 0) {
1531
+ if (this.oneShot !== null) {
1532
+ this.applyOneShot(deltaTime);
1533
+ }
1534
+ else if (this.blendEntries !== null && this.blendEntries.length > 0) {
1437
1535
  this.applyBlendedPose(this.blendEntries);
1438
1536
  }
1439
1537
  else if (this.crossfade !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.29.1",
3
+ "version": "0.30.1",
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/engine.ts CHANGED
@@ -106,6 +106,13 @@ const PRESET_NAME_HINTS: Array<[MaterialPreset, string[]]> = [
106
106
  "尾",
107
107
  "套", // 外套 (coat), 手套 (gloves)
108
108
  "腿", // 腿环 (leg ring/garter) and other leg-wear accessories
109
+ "带", // straps and bands: 头带/发带/背带/腰带
110
+ "绳", // ropes: 背绳/腰绳
111
+ "纱", // gauze/veils: 头纱
112
+ "肩布", // shoulder cloth/drape
113
+ "背球", // back ornament sphere
114
+ "腰花", // waist flower
115
+ "花蕊", // flower pistil ornament
109
116
  "skirt",
110
117
  "dress",
111
118
  "ribbon",
@@ -367,6 +374,9 @@ export interface EngineStats {
367
374
  frameTimeMax: number // ms — worst frame interval in the window (hitch / stutter indicator)
368
375
  fps1PercentLow: number // "1% low" fps = 1000 / 99th-percentile frame interval
369
376
  jitter: number // ms — stddev of frame intervals (pacing evenness; high = janky at any mean fps)
377
+ cpuAnimMs: number // ms/frame (EMA) — model updates: blending, IK, world matrices
378
+ cpuPhysicsMs: number // ms/frame (EMA) — physics stepping across all instances
379
+ cpuRenderMs: number // ms/frame (EMA) — the rest of the render thread: uniforms, encoding, submit
370
380
  }
371
381
 
372
382
  type DrawCallType = "opaque" | "transparent" | "ground" | "opaque-outline" | "transparent-outline"
@@ -815,6 +825,9 @@ export class Engine {
815
825
  frameTime: 0,
816
826
  frameTimeMax: 0,
817
827
  fps1PercentLow: 0,
828
+ cpuAnimMs: 0,
829
+ cpuPhysicsMs: 0,
830
+ cpuRenderMs: 0,
818
831
  jitter: 0,
819
832
  }
820
833
  private animationFrameId: number | null = null
@@ -2949,17 +2962,35 @@ export class Engine {
2949
2962
  return { ...this.stats }
2950
2963
  }
2951
2964
 
2965
+ /** Frame-rate cap for the render loop, or null for display-rate. On high-refresh
2966
+ * displays (144/240Hz) rAF runs the WHOLE pipeline — physics, IK, blending,
2967
+ * passes — that many times per second for no visible gain over ~120 (VMD content
2968
+ * is 30fps; blending interpolates). Capping restores the per-second budget. */
2969
+ private maxFPS: number | null = null
2970
+ private lastLoopTime = 0
2971
+
2972
+ setMaxFPS(fps: number | null): void {
2973
+ this.maxFPS = fps !== null && fps > 0 ? fps : null
2974
+ }
2975
+
2952
2976
  runRenderLoop(callback?: () => void) {
2953
2977
  this.renderLoopCallback = callback || null
2954
2978
 
2955
- const loop = () => {
2979
+ const loop = (now: number) => {
2980
+ this.animationFrameId = requestAnimationFrame(loop)
2981
+
2982
+ if (this.maxFPS !== null) {
2983
+ // Tolerate rAF jitter: accept frames within ~half a display tick early.
2984
+ const minInterval = 1000 / this.maxFPS - 2
2985
+ if (now - this.lastLoopTime < minInterval) return
2986
+ this.lastLoopTime = now
2987
+ }
2988
+
2956
2989
  this.render()
2957
2990
 
2958
2991
  if (this.renderLoopCallback) {
2959
2992
  this.renderLoopCallback()
2960
2993
  }
2961
-
2962
- this.animationFrameId = requestAnimationFrame(loop)
2963
2994
  }
2964
2995
 
2965
2996
  this.animationFrameId = requestAnimationFrame(loop)
@@ -3220,9 +3251,22 @@ export class Engine {
3220
3251
  for (const inst of this.modelInstances.values()) fn(inst)
3221
3252
  }
3222
3253
 
3254
+ // CPU frame-time breakdown (EMA-smoothed into getStats): where a frame's
3255
+ // milliseconds actually go — animation/IK/blending vs physics vs everything
3256
+ // else on the render thread. The first question of any perf report.
3257
+ private cpuAnimMs = 0
3258
+ private cpuPhysicsMs = 0
3259
+ private cpuRenderMs = 0
3260
+ private frameAnimMsRaw = 0
3261
+ private framePhysicsMsRaw = 0
3262
+
3223
3263
  private updateInstances(deltaTime: number): void {
3264
+ let animMs = 0
3265
+ let physicsMs = 0
3224
3266
  this.forEachInstance((inst) => {
3267
+ const tAnim = performance.now()
3225
3268
  const verticesChanged = inst.model.update(deltaTime, this.ikEnabled)
3269
+ animMs += performance.now() - tAnim
3226
3270
  if (inst.gpuMorph) {
3227
3271
  // GPU path: on a weight change, upload effective weights (thresholding tiny values
3228
3272
  // to 0 to match the CPU skip) and flag the compute dispatch for this frame.
@@ -3241,10 +3285,17 @@ export class Engine {
3241
3285
  inst.vertexBufferNeedsUpdate = true
3242
3286
  }
3243
3287
  if (inst.physics && this.physicsEnabled) {
3288
+ const tPhys = performance.now()
3244
3289
  inst.physics.step(deltaTime, inst.model.getWorldMatrices(), inst.model.getBoneInverseBindMatrices())
3290
+ physicsMs += performance.now() - tPhys
3245
3291
  }
3246
3292
  if (inst.vertexBufferNeedsUpdate) this.updateVertexBuffer(inst)
3247
3293
  })
3294
+ this.frameAnimMsRaw = animMs
3295
+ this.framePhysicsMsRaw = physicsMs
3296
+ const EMA = 0.1
3297
+ this.cpuAnimMs += (animMs - this.cpuAnimMs) * EMA
3298
+ this.cpuPhysicsMs += (physicsMs - this.cpuPhysicsMs) * EMA
3248
3299
  }
3249
3300
 
3250
3301
  private updateVertexBuffer(inst: ModelInstance): void {
@@ -3727,11 +3778,6 @@ export class Engine {
3727
3778
  // misclassified fully-worn opaque dresses (avg 0.69) as veils and stripped
3728
3779
  // their shadows, while any lower cliff would strand the next model.
3729
3780
  const castsShadow = (mat.edgeFlag & 0x04) !== 0
3730
- // Load-time classification log — one line per material, cheap and
3731
- // invaluable when a model renders wrong (bucket/outline/shadow disputes).
3732
- console.info(
3733
- `[reze] ${mat.name}: alpha=${materialAlpha.toFixed(2)} avg=${stats.avg.toFixed(2)} translucentFrac=${stats.translucentFrac.toFixed(2)} bucket=${isTransparent ? "transparent" : "opaque"} castsShadow=${castsShadow} edge=${(mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0 ? "on" : "off"}`,
3734
- )
3735
3781
 
3736
3782
  // Sphere map (sph=1 multiply / spa=2 add). Mode 3 (sub-texture UV) is
3737
3783
  // rare and not implemented — treated as none, like a failed load.
@@ -4606,6 +4652,9 @@ export class Engine {
4606
4652
  }
4607
4653
 
4608
4654
  private renderWithDelta(deltaTime: number) {
4655
+ const tFrame = performance.now()
4656
+ this.frameAnimMsRaw = 0
4657
+ this.framePhysicsMsRaw = 0
4609
4658
  if (this.resizePending) {
4610
4659
  this.resizePending = false
4611
4660
  this.handleResize()
@@ -4757,6 +4806,10 @@ export class Engine {
4757
4806
 
4758
4807
  this.device.queue.submit([encoder.finish()])
4759
4808
 
4809
+ // Everything this frame that wasn't animation or physics: uniforms, encoding, submit.
4810
+ const renderOnly = performance.now() - tFrame - this.frameAnimMsRaw - this.framePhysicsMsRaw
4811
+ this.cpuRenderMs += (renderOnly - this.cpuRenderMs) * 0.1
4812
+
4760
4813
  if (pick) {
4761
4814
  this.pendingPick = null
4762
4815
  const dpr = window.devicePixelRatio || 1
@@ -5387,5 +5440,8 @@ export class Engine {
5387
5440
  this.stats.frameTimeMax = Math.round(max * 100) / 100
5388
5441
  this.stats.fps1PercentLow = p99 > 0 ? Math.round(1000 / p99) : 0
5389
5442
  this.stats.jitter = Math.round(stddev * 100) / 100
5443
+ this.stats.cpuAnimMs = Math.round(this.cpuAnimMs * 100) / 100
5444
+ this.stats.cpuPhysicsMs = Math.round(this.cpuPhysicsMs * 100) / 100
5445
+ this.stats.cpuRenderMs = Math.round(this.cpuRenderMs * 100) / 100
5390
5446
  }
5391
5447
  }
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ export {
70
70
  type LocomotionClips,
71
71
  type LocomotionOptions,
72
72
  type LocomotionPose,
73
+ type StrafeClipEntry,
73
74
  } from "./locomotion"
74
75
  export {
75
76
  FPS,
package/src/locomotion.ts CHANGED
@@ -8,11 +8,24 @@ import { Model } from "./model"
8
8
  import { FPS, type BlendEntry } from "./animation"
9
9
  import { Quat, Vec3 } from "./math"
10
10
 
11
+ export interface StrafeClipEntry {
12
+ /** Clip name previously loaded on the model. */
13
+ clip: string
14
+ /** Movement direction relative to the facing, radians: 0 = forward, + = the character's right. */
15
+ angle: number
16
+ /** The clip's authored root speed in MMD units/s (post-conversion scale) — drives root motion. */
17
+ speed: number
18
+ }
19
+
11
20
  export interface LocomotionClips {
12
21
  /** Clip names previously loaded on the model (loadVmd/loadClip). */
13
22
  idle: string
14
23
  run: string
15
24
  sprint?: string
25
+ /** Directional ring for strafe mode (setFacing): body holds a facing while movement
26
+ * blends the two ring clips nearest the local move angle. */
27
+ strafeRun?: StrafeClipEntry[]
28
+ strafeSprint?: StrafeClipEntry[]
16
29
  }
17
30
 
18
31
  export interface LocomotionOptions {
@@ -93,6 +106,11 @@ export class LocomotionController {
93
106
 
94
107
  private readonly entries: BlendEntry[]
95
108
  private readonly pose: LocomotionPose
109
+ // Strafe mode: non-null = the world yaw the body holds (movement decoupled from facing).
110
+ private facingYaw: number | null = null
111
+ private readonly strafeRun: StrafeClipEntry[] | null
112
+ private readonly strafeSprint: StrafeClipEntry[] | null
113
+ private readonly strafeEntries: BlendEntry[]
96
114
 
97
115
  constructor(model: Model, clips: LocomotionClips, options?: LocomotionOptions) {
98
116
  this.model = model
@@ -110,6 +128,11 @@ export class LocomotionController {
110
128
  { name: clips.run, time: 0, weight: 0 },
111
129
  { name: clips.sprint ?? clips.run, time: 0, weight: 0 },
112
130
  ]
131
+ const byAngle = (a: StrafeClipEntry, b: StrafeClipEntry) => a.angle - b.angle
132
+ this.strafeRun = clips.strafeRun ? [...clips.strafeRun].sort(byAngle) : null
133
+ this.strafeSprint = clips.strafeSprint ? [...clips.strafeSprint].sort(byAngle) : null
134
+ // idle + adjacent pair per gear; unused slots keep weight 0 and are skipped.
135
+ this.strafeEntries = Array.from({ length: 5 }, () => ({ name: clips.idle, time: 0, weight: 0 }))
113
136
  this.pose = { position: this.position, yaw: 0, rotation: this.rotation, speedLevel: 0 }
114
137
  }
115
138
 
@@ -145,6 +168,13 @@ export class LocomotionController {
145
168
  this.yaw = yaw
146
169
  }
147
170
 
171
+ /** Strafe mode: hold the body at this world yaw (a camera forward, a lock-on target)
172
+ * while setMove's vector drives the directional strafe ring — requires
173
+ * clips.strafeRun. null returns to turn-toward-movement. */
174
+ setFacing(yaw: number | null): void {
175
+ this.facingYaw = this.strafeRun ? yaw : null
176
+ }
177
+
148
178
  getPosition(): Vec3 {
149
179
  return this.position
150
180
  }
@@ -164,6 +194,10 @@ export class LocomotionController {
164
194
  update(dt: number): LocomotionPose {
165
195
  if (dt > 0.1) dt = 0.1 // tab-switch guard: never integrate a huge step
166
196
 
197
+ if (this.facingYaw !== null && this.strafeRun !== null && !this.tankMode) {
198
+ return this.updateStrafe(dt)
199
+ }
200
+
167
201
  const hasSprint = this.clips.sprint !== undefined
168
202
  let moving: boolean
169
203
  let align = 1
@@ -198,9 +232,11 @@ export class LocomotionController {
198
232
  }
199
233
 
200
234
  // Speed level ramps linearly toward the target; the pose blend follows it.
201
- // (No sprint while backpedaling.)
235
+ // Input magnitude scales the target (analog sticks and arrival steering slow
236
+ // down instead of overshooting). No sprint while backpedaling.
202
237
  const sprinting = this.inputSprint && hasSprint && !(this.tankMode && this.inputForward < 0)
203
- const target = moving ? (sprinting ? 2 : 1) : 0
238
+ const magnitude = this.tankMode ? Math.abs(this.inputForward) : Math.min(1, Math.hypot(this.inputX, this.inputY))
239
+ const target = moving ? (sprinting ? 2 : 1) * magnitude : 0
204
240
  const maxStep = this.speedResponse * dt
205
241
  const d = target - this.speedLevel
206
242
  this.speedLevel += Math.abs(d) <= maxStep ? d : Math.sign(d) * maxStep
@@ -251,4 +287,112 @@ export class LocomotionController {
251
287
  this.pose.speedLevel = this.speedLevel
252
288
  return this.pose
253
289
  }
290
+
291
+ /** The two ring clips straddling `local` (radians rel. facing), with the blend
292
+ * fraction between them. The ring is sorted; the last→first gap wraps. */
293
+ private ringPair(ring: StrafeClipEntry[], local: number): { a: StrafeClipEntry; b: StrafeClipEntry; t: number } {
294
+ const n = ring.length
295
+ for (let i = 0; i < n; i++) {
296
+ const a = ring[i]
297
+ const b = ring[(i + 1) % n]
298
+ const gap = (((b.angle - a.angle) % TWO_PI) + TWO_PI) % TWO_PI || TWO_PI
299
+ const rel = (((local - a.angle) % TWO_PI) + TWO_PI) % TWO_PI
300
+ if (rel <= gap + 1e-9) return { a, b, t: rel / gap }
301
+ }
302
+ return { a: ring[0], b: ring[0], t: 0 }
303
+ }
304
+
305
+ /** Strafe-mode frame: the body holds facingYaw; movement blends the two ring clips
306
+ * nearest the local move angle, per gear, all sharing one gait phase. Root motion
307
+ * follows the input direction at the pair's authored (angle-lerped) speed. */
308
+ private updateStrafe(dt: number): LocomotionPose {
309
+ const runRing = this.strafeRun!
310
+ const sprintRing = this.strafeSprint
311
+
312
+ this.yaw = wrapAngle(this.yaw + wrapAngle(this.facingYaw! - this.yaw) * Math.min(1, this.turnResponse * dt))
313
+
314
+ const m = Math.hypot(this.inputX, this.inputY)
315
+ const moving = m > 0.05
316
+ const sprinting = this.inputSprint && sprintRing !== null
317
+ const target = moving ? (sprinting ? 2 : 1) * Math.min(1, m) : 0
318
+ const maxStep = this.speedResponse * dt
319
+ const d = target - this.speedLevel
320
+ this.speedLevel += Math.abs(d) <= maxStep ? d : Math.sign(d) * maxStep
321
+ const level = this.speedLevel
322
+
323
+ let runA = runRing[0]
324
+ let runB = runRing[0]
325
+ let runT = 0
326
+ let sprintA = sprintRing ? sprintRing[0] : runRing[0]
327
+ let sprintB = sprintA
328
+ let sprintT = 0
329
+ if (moving) {
330
+ const local = wrapAngle(Math.atan2(this.inputX, this.inputY) - this.yaw)
331
+ const rp = this.ringPair(runRing, local)
332
+ runA = rp.a
333
+ runB = rp.b
334
+ runT = rp.t
335
+ if (sprintRing) {
336
+ const sp = this.ringPair(sprintRing, local)
337
+ sprintA = sp.a
338
+ sprintB = sp.b
339
+ sprintT = sp.t
340
+ }
341
+ this.dirX = this.inputX / m
342
+ this.dirZ = this.inputY / m
343
+ }
344
+
345
+ // Root motion at the authored (angle-lerped, gear-lerped) clip speed.
346
+ const runSpeed = runA.speed + (runB.speed - runA.speed) * runT
347
+ const topSpeed = sprintRing ? sprintA.speed + (sprintB.speed - sprintA.speed) * sprintT : runSpeed
348
+ const speed = level <= 1 ? runSpeed * level : runSpeed + (topSpeed - runSpeed) * (level - 1)
349
+ this.position.x += this.dirX * speed * dt
350
+ this.position.z += this.dirZ * speed * dt
351
+
352
+ // Clocks: idle free-runs; the ring shares one phase (durations are uniform per gear).
353
+ const idleDur = this.clipDuration(this.clips.idle)
354
+ const runDur = this.clipDuration(runA.clip)
355
+ const sprintDur = sprintRing ? this.clipDuration(sprintA.clip) : runDur
356
+ this.idleTime = (this.idleTime + dt) % idleDur
357
+ const gaitDur = level <= 1 ? runDur : runDur + (sprintDur - runDur) * (level - 1)
358
+ this.gaitPhase = (this.gaitPhase + dt / gaitDur) % 1
359
+
360
+ let wIdle: number
361
+ let wRun: number
362
+ let wSprint: number
363
+ if (level <= 1) {
364
+ wIdle = 1 - level
365
+ wRun = level
366
+ wSprint = 0
367
+ } else {
368
+ wIdle = 0
369
+ wRun = 2 - level
370
+ wSprint = level - 1
371
+ }
372
+
373
+ const e = this.strafeEntries
374
+ e[0].name = this.clips.idle
375
+ e[0].time = this.idleTime
376
+ e[0].weight = wIdle
377
+ e[1].name = runA.clip
378
+ e[1].time = this.gaitPhase * runDur
379
+ e[1].weight = wRun * (1 - runT)
380
+ e[2].name = runB.clip
381
+ e[2].time = this.gaitPhase * runDur
382
+ e[2].weight = wRun * runT
383
+ e[3].name = sprintA.clip
384
+ e[3].time = this.gaitPhase * sprintDur
385
+ e[3].weight = wSprint * (1 - sprintT)
386
+ e[4].name = sprintB.clip
387
+ e[4].time = this.gaitPhase * sprintDur
388
+ e[4].weight = wSprint * sprintT
389
+ this.model.setBlendPose(e)
390
+
391
+ const ry = this.yaw + this.yawOffset
392
+ const half = ry * 0.5
393
+ this.rotation.setXYZW(0, Math.sin(half), 0, Math.cos(half))
394
+ this.pose.yaw = this.yaw
395
+ this.pose.speedLevel = level
396
+ return this.pose
397
+ }
254
398
  }