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/README.md +19 -0
- package/dist/engine.d.ts +15 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +56 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/locomotion.d.ts +27 -0
- package/dist/locomotion.d.ts.map +1 -1
- package/dist/locomotion.js +120 -2
- package/dist/model.d.ts +18 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +99 -1
- package/package.json +1 -1
- package/src/engine.ts +64 -8
- package/src/index.ts +1 -0
- package/src/locomotion.ts +146 -2
- package/src/model.ts +107 -1
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.
|
|
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
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
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
|
-
// (
|
|
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
|
|
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
|
}
|