@driftengine/animation 3.61.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/spring.ts ADDED
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Secondary motion: hair, a coat, an antenna, a chain, reacting to what the subject is doing.
3
+ *
4
+ * **A pure function of the time it is asked about, which is the whole reason this is not a
5
+ * ragdoll.** The consumer who asked for it refused ragdoll in writing and gave the reason: a
6
+ * ragdoll integrates state, so it cannot answer *what is the pose at time t* for a `t` reached by
7
+ * dragging a playhead backwards. Their preview reads one sampler against an audio clock and their
8
+ * export reads the same one against a frame index; anything that accumulates makes those two
9
+ * disagree, and makes two exports of one project differ.
10
+ *
11
+ * **What makes a pure version possible is that a damped spring forgets.** The influence of the
12
+ * anchor at time τ on the mass at time t decays as `e^(-ζω(t-τ))`, so past some settle time it
13
+ * is below any tolerance worth naming. Evaluating at `t` is therefore: start at rest a settle time
14
+ * earlier, march forward, return. Bounded work, no history, and the same answer whichever way the
15
+ * caller scrubs.
16
+ *
17
+ * **What that costs is arithmetic per sample rather than per frame**, and it is the trade the
18
+ * request asks for. A spring at 2 Hz and 0.35 damping settles in about 1.7 s, which at this
19
+ * module's substep is a few dozen steps of a 2x2 multiply — microseconds, paid whether the caller
20
+ * is playing forward or dragging backwards, which is the point.
21
+ *
22
+ * **Nothing here reads a clock**, allocates after module load, or holds state between calls, which
23
+ * is `clip.ts`'s contract and the reason this belongs beside it rather than in physics.
24
+ */
25
+
26
+ /** Three floats, as everything in this package passes positions. */
27
+ type Vec3Out = Float32Array;
28
+
29
+ /** Where the mass is being pulled, as a pure function of time. Writes three floats into `out`. */
30
+ export type SpringAnchor = (timeSec: number, out: Vec3Out) => void;
31
+
32
+ export interface SpringSettings {
33
+ /**
34
+ * How fast the spring wants to oscillate, in hertz.
35
+ *
36
+ * This is the *undamped* natural frequency: the rate it would ring at with no damping at all.
37
+ * Hair is a few hertz; a heavy coat is under one.
38
+ */
39
+ readonly frequencyHz: number;
40
+ /**
41
+ * The damping ratio. Below 1 overshoots and rings, 1 is critical and never overshoots, above 1
42
+ * sags in without reaching. **Zero is refused** — see `springSettleSec`.
43
+ */
44
+ readonly damping: number;
45
+ /**
46
+ * How far the mass may fall behind its anchor, in metres. Absent means no limit.
47
+ *
48
+ * A limit rather than a stiffness change, because the two are different requests: stiffening
49
+ * changes how the whole motion reads, and this is for the one frame in a plan where a whip pan
50
+ * would otherwise leave a braid behind in the air.
51
+ */
52
+ readonly maxOffsetM?: number;
53
+ }
54
+
55
+ /**
56
+ * One link of a chain: a spring, plus where it sits relative to the link above it at rest.
57
+ *
58
+ * **The offset is what makes a chain hang rather than collapse.** Without it every link would be
59
+ * pulled to the same point and a braid would be a single mass with extra arithmetic.
60
+ */
61
+ export interface SpringLink extends SpringSettings {
62
+ /** Where this link rests relative to the one above it, in metres. */
63
+ readonly restOffsetM: readonly [number, number, number];
64
+ }
65
+
66
+ /**
67
+ * How long until the spring's memory of an input is negligible.
68
+ *
69
+ * **This is the lookback, and it is derived rather than dialled.** The envelope of a damped
70
+ * oscillator decays as `e^(-ζωt)`, so reaching a factor `EPSILON` takes `-ln(EPSILON) / (ζω)`.
71
+ * Everything older than that contributes less than a thousandth of the anchor's own motion, which
72
+ * is well under a pixel on anything this drives.
73
+ *
74
+ * **Damping of zero has no answer here and is refused at the sampler.** An undamped spring rings
75
+ * forever, so no lookback is long enough and there is no honest value to return — the whole
76
+ * argument for sampling this purely is the decay, and at ζ = 0 there is none.
77
+ */
78
+ export function springSettleSec(settings: SpringSettings): number {
79
+ const omega = 2 * Math.PI * settings.frequencyHz;
80
+ return -Math.log(EPSILON) / (settings.damping * omega);
81
+ }
82
+
83
+ /**
84
+ * The same, for a chain, which remembers longer than any of its links.
85
+ *
86
+ * **A cascade does not forget at its slowest link's rate.** A disturbance entering the top has to
87
+ * travel down before it can die out, so the composite response carries a polynomial in `t`
88
+ * alongside the exponential and the settle time grows with depth. Taking one link's lookback for
89
+ * four would leave the sampler carrying a start transient into its answer, which is exactly the
90
+ * defect the purity test exists to catch.
91
+ *
92
+ * The bound is the survival function of the cascade — `e^(-x) Σ x^k/k!` for `k` under the link
93
+ * count, with `x = σt` — solved for `EPSILON`. **`σ` is the slowest link and the depth is taken as
94
+ * a full multiplicity**, which is the worst case: links that differ have distinct poles and settle
95
+ * sooner than this says. Erring long costs substeps; erring short costs correctness.
96
+ */
97
+ export function springChainSettleSec(links: readonly SpringLink[]): number {
98
+ if (links.length === 0) return 0;
99
+
100
+ let slowest = Infinity;
101
+ for (const link of links) {
102
+ validateSettings(link);
103
+ slowest = Math.min(slowest, link.damping * 2 * Math.PI * link.frequencyHz);
104
+ }
105
+
106
+ /* Bracket by doubling rather than by a guessed constant: the root grows with depth, and a
107
+ bracket that is too tight would silently return its own upper bound. */
108
+ let high = -Math.log(EPSILON) + links.length;
109
+ while (cascadeSurvival(high, links.length) > EPSILON) high *= 2;
110
+
111
+ let low = 0;
112
+ /* A fixed count rather than a tolerance, so the answer is the same on every machine. Sixty-four
113
+ halvings take the bracket below what a double can represent. */
114
+ for (let n = 0; n < 64; n += 1) {
115
+ const middle = (low + high) / 2;
116
+ if (cascadeSurvival(middle, links.length) > EPSILON) low = middle;
117
+ else high = middle;
118
+ }
119
+ return high / slowest;
120
+ }
121
+
122
+ /**
123
+ * `e^(-x) Σ x^k/k!` for `k` below `count`: how much of a disturbance a cascade still holds.
124
+ *
125
+ * Built up from `e^(-x)` and multiplied down rather than summing `x^k/k!` and scaling at the end,
126
+ * because every term of this form is at most 1 while `x^k` alone overflows for a deep chain.
127
+ */
128
+ function cascadeSurvival(x: number, count: number): number {
129
+ let term = Math.exp(-x);
130
+ let sum = term;
131
+ for (let k = 1; k < count; k += 1) {
132
+ term *= x / k;
133
+ sum += term;
134
+ }
135
+ return sum;
136
+ }
137
+
138
+ /** A thousandth of the anchor's own motion: below a pixel on anything a spring drives. */
139
+ const EPSILON = 1e-3;
140
+
141
+ /**
142
+ * Substeps per oscillation period.
143
+ *
144
+ * **Measured against the reference march in the tests, and the measurement is why the integrator
145
+ * below is the one it is.** The first attempt stepped the equation with semi-implicit Euler, whose
146
+ * error goes as the step: against a four-thousand-step march from the beginning of time it missed
147
+ * by 0.058 at thirty-two steps a period, 0.029 at sixty-four and 0.0075 at two hundred and
148
+ * fifty-six — a clean halving, which is first order, and which said the *integrator* carried the
149
+ * error and not the lookback. Holding a hundredth needed about a thousand steps a period, three
150
+ * thousand per joint per frame, which is not a number worth paying for hair.
151
+ *
152
+ * Solving each substep in closed form instead leaves only the anchor's own sampling, and that error
153
+ * goes as the step squared: **1.7e-2, 4.1e-3, 1.2e-3 and 4.0e-4 at four, eight, sixteen and
154
+ * thirty-two**, a quarter per doubling, flooring at 2.3e-4 — which is the reference's own error and
155
+ * not this one's.
156
+ *
157
+ * **Thirty-two, for the anchor rather than for the spring.** Sixteen already holds the assertion.
158
+ * What thirty-two buys is the rate the anchor is read at, which is this number times the spring's
159
+ * frequency: 64 Hz for a 2 Hz spring, comfortably above what a plan at 24 or 30 fps carries, where
160
+ * sixteen would sit right on it. Ninety-odd steps of a four-multiply 2x2.
161
+ */
162
+ const STEPS_PER_PERIOD = 32;
163
+
164
+ /**
165
+ * How close to `1` counts as critically damped.
166
+ *
167
+ * Below it the ringing and sagging forms both divide by a frequency that has gone to zero. The
168
+ * band is far narrower than any setting a person types, and the closed form is continuous across
169
+ * it, so which side a value lands on is not visible in the motion.
170
+ */
171
+ const CRITICAL_BAND = 1e-12;
172
+
173
+ /**
174
+ * Scratch, allocated once: this is called per joint per frame and may not allocate.
175
+ *
176
+ * The anchor pair is `Float32Array` because that is what a `SpringAnchor` writes, and the state is
177
+ * double because it is carried across every substep while the anchor is only read.
178
+ */
179
+ const ANCHOR = new Float32Array(3);
180
+ const PREVIOUS = new Float32Array(3);
181
+ const POSITION = new Float64Array(3);
182
+ const VELOCITY = new Float64Array(3);
183
+ /** Four transition coefficients, which for a single spring is one link's worth. */
184
+ const COEFFICIENTS = new Float64Array(4);
185
+
186
+ /**
187
+ * Where the mass sits at `timeSec`, given where its anchor has been.
188
+ *
189
+ * `anchorAt` is called many times per sample, at times before `timeSec`, and must be a pure
190
+ * function of the time it is handed — the same contract `sampleClip` holds. An anchor that reads a
191
+ * clock, or that answers differently on a second call for one time, breaks the property this
192
+ * module exists to provide and does so silently.
193
+ */
194
+ export function sampleSpring(
195
+ settings: SpringSettings,
196
+ anchorAt: SpringAnchor,
197
+ timeSec: number,
198
+ out: Vec3Out,
199
+ ): void {
200
+ validateSettings(settings);
201
+
202
+ const lookback = springSettleSec(settings);
203
+ const start = timeSec - lookback;
204
+
205
+ /*
206
+ * **At rest on the anchor rather than at the origin.** A spring started at zero would spend its
207
+ * first settle time flying in from wherever the scene's origin happens to be, and since the
208
+ * lookback ends exactly where the caller asked, that transient would arrive in the answer. The
209
+ * anchor's own position is the only rest state that is right for every scene.
210
+ */
211
+ anchorAt(start, PREVIOUS);
212
+ POSITION[0] = PREVIOUS[0]!;
213
+ POSITION[1] = PREVIOUS[1]!;
214
+ POSITION[2] = PREVIOUS[2]!;
215
+ VELOCITY[0] = 0;
216
+ VELOCITY[1] = 0;
217
+ VELOCITY[2] = 0;
218
+
219
+ const steps = Math.max(1, Math.ceil(lookback * settings.frequencyHz * STEPS_PER_PERIOD));
220
+ const step = lookback / steps;
221
+ const trail = trailPerUnitSpeed(settings);
222
+ transitionOver(settings, step, COEFFICIENTS, 0);
223
+
224
+ for (let n = 1; n <= steps; n += 1) {
225
+ anchorAt(start + n * step, ANCHOR);
226
+ for (let axis = 0; axis < 3; axis += 1) {
227
+ advance(
228
+ COEFFICIENTS,
229
+ 0,
230
+ trail,
231
+ step,
232
+ POSITION,
233
+ VELOCITY,
234
+ axis,
235
+ PREVIOUS[axis]!,
236
+ ANCHOR[axis]!,
237
+ );
238
+ PREVIOUS[axis] = ANCHOR[axis]!;
239
+ }
240
+ }
241
+
242
+ anchorAt(timeSec, ANCHOR);
243
+ writeClamped(settings.maxOffsetM, POSITION, 0, ANCHOR[0]!, ANCHOR[1]!, ANCHOR[2]!, out, 0);
244
+ }
245
+
246
+ /**
247
+ * Where every link of a chain sits at `timeSec`. Writes three floats per link into `out`.
248
+ *
249
+ * **The whole chain is marched on one grid rather than each link on its own**, and that is not an
250
+ * optimisation. Link `n`'s anchor is link `n-1`'s *sprung* position, so sampling a link
251
+ * independently would need its parent at every substep, which would need the grandparent at every
252
+ * substep of every one of those: a depth-`d` chain would cost `steps^d` anchor calls. Marched
253
+ * together it is `steps × d`, and it is the same answer.
254
+ */
255
+ export function sampleSpringChain(
256
+ links: readonly SpringLink[],
257
+ rootAt: SpringAnchor,
258
+ timeSec: number,
259
+ out: Float32Array,
260
+ ): void {
261
+ const count = links.length;
262
+ if (count === 0) return;
263
+ if (out.length < 3 * count) {
264
+ throw new Error(
265
+ `spring chain: out holds ${out.length} floats and this chain needs ${3 * count}, ` +
266
+ `three per link for ${count} links.`,
267
+ );
268
+ }
269
+
270
+ /* Validates every link on the way, so a bad setting is refused before any marching. */
271
+ const lookback = springChainSettleSec(links);
272
+ const start = timeSec - lookback;
273
+ reserve(count);
274
+
275
+ /*
276
+ * **The substep resolves the fastest link, and the lookback the slowest.** A chain mixing the
277
+ * two is expensive by exactly as much as it is asking for: a 9 Hz link needs a fine step, a 1 Hz
278
+ * link at a tenth damping needs a long memory, and a chain with both needs both.
279
+ */
280
+ let fastest = 0;
281
+ for (const link of links) fastest = Math.max(fastest, link.frequencyHz);
282
+ const steps = Math.max(1, Math.ceil(lookback * fastest * STEPS_PER_PERIOD));
283
+ const step = lookback / steps;
284
+
285
+ for (let i = 0; i < count; i += 1) {
286
+ transitionOver(links[i]!, step, chainCoefficients, 4 * i);
287
+ chainTrail[i] = trailPerUnitSpeed(links[i]!);
288
+ }
289
+
290
+ /* At rest, hanging: each link starts on its own rest offset below the one above it. */
291
+ rootAt(start, PREVIOUS);
292
+ for (let i = 0; i < count; i += 1) {
293
+ const rest = links[i]!.restOffsetM;
294
+ for (let axis = 0; axis < 3; axis += 1) {
295
+ const above = i === 0 ? PREVIOUS[axis]! : chainPosition[3 * (i - 1) + axis]!;
296
+ chainPosition[3 * i + axis] = above + rest[axis]!;
297
+ chainVelocity[3 * i + axis] = 0;
298
+ }
299
+ }
300
+
301
+ for (let n = 1; n <= steps; n += 1) {
302
+ rootAt(start + n * step, ANCHOR);
303
+ for (let axis = 0; axis < 3; axis += 1) {
304
+ /*
305
+ * Swept from the root down, carrying the link above's two positions — where it was when the
306
+ * substep began and where it ended up. Both are needed because the closed form below solves
307
+ * a step of an anchor that is *moving*, and the link above is the anchor.
308
+ */
309
+ let abovePrevious = PREVIOUS[axis]!;
310
+ let aboveNow = ANCHOR[axis]!;
311
+ for (let i = 0; i < count; i += 1) {
312
+ const at = 3 * i + axis;
313
+ const rest = links[i]!.restOffsetM[axis]!;
314
+ const mine = chainPosition[at]!;
315
+ advance(
316
+ chainCoefficients,
317
+ 4 * i,
318
+ chainTrail[i]!,
319
+ step,
320
+ chainPosition,
321
+ chainVelocity,
322
+ at,
323
+ abovePrevious + rest,
324
+ aboveNow + rest,
325
+ );
326
+ abovePrevious = mine;
327
+ aboveNow = chainPosition[at]!;
328
+ }
329
+ PREVIOUS[axis] = ANCHOR[axis]!;
330
+ }
331
+ }
332
+
333
+ /*
334
+ * The clamp walks the finished chain rather than riding along inside it, for the reason
335
+ * `writeClamped` gives — and down the *clamped* positions, so a link held back brings the ones
336
+ * below it along instead of leaving them stretched away from a parent that moved.
337
+ */
338
+ rootAt(timeSec, ANCHOR);
339
+ let aboveX = ANCHOR[0]!;
340
+ let aboveY = ANCHOR[1]!;
341
+ let aboveZ = ANCHOR[2]!;
342
+ for (let i = 0; i < count; i += 1) {
343
+ const rest = links[i]!.restOffsetM;
344
+ writeClamped(
345
+ links[i]!.maxOffsetM,
346
+ chainPosition,
347
+ 3 * i,
348
+ aboveX + rest[0]!,
349
+ aboveY + rest[1]!,
350
+ aboveZ + rest[2]!,
351
+ out,
352
+ 3 * i,
353
+ );
354
+ aboveX = out[3 * i]!;
355
+ aboveY = out[3 * i + 1]!;
356
+ aboveZ = out[3 * i + 2]!;
357
+ }
358
+ }
359
+
360
+ /** Chain scratch, grown when a longer chain than any seen before arrives and never shrunk. */
361
+ let chainPosition = new Float64Array(0);
362
+ let chainVelocity = new Float64Array(0);
363
+ let chainCoefficients = new Float64Array(0);
364
+ let chainTrail = new Float64Array(0);
365
+
366
+ /**
367
+ * Make room for `count` links.
368
+ *
369
+ * **Grown on demand rather than capped**, because a cap is a number this module cannot know: a
370
+ * braid is four links and a chain-mail skirt is a hundred. After the first frame of the longest
371
+ * chain a scene holds this allocates nothing, which is what `clip.ts` asks for — the rule is no
372
+ * allocation *per frame*, not none ever.
373
+ */
374
+ function reserve(count: number): void {
375
+ if (chainTrail.length >= count) return;
376
+ chainPosition = new Float64Array(3 * count);
377
+ chainVelocity = new Float64Array(3 * count);
378
+ chainCoefficients = new Float64Array(4 * count);
379
+ chainTrail = new Float64Array(count);
380
+ }
381
+
382
+ function validateSettings(settings: SpringSettings): void {
383
+ if (!(settings.frequencyHz > 0)) {
384
+ throw new Error(
385
+ `spring: frequencyHz is ${settings.frequencyHz}, and a spring needs a frequency above zero. ` +
386
+ 'It is the rate the spring would ring at undamped, in hertz.',
387
+ );
388
+ }
389
+ if (!(settings.damping >= 0)) {
390
+ throw new Error(`spring: damping is ${settings.damping}, and a damping ratio is not negative.`);
391
+ }
392
+ if (settings.damping === 0) {
393
+ throw new Error(
394
+ 'spring: damping is 0, which never settles — an undamped spring rings forever, so no ' +
395
+ 'lookback is long enough to sample it purely and there is no honest answer to give. ' +
396
+ 'Anything above zero forgets; 1 is critical damping, which never overshoots.',
397
+ );
398
+ }
399
+ }
400
+
401
+ /**
402
+ * How far a mass trails an anchor moving at unit speed.
403
+ *
404
+ * Write the offset from the anchor as `r = x - a`. With the anchor moving at a constant `va`,
405
+ * `r'' + 2ζω r' + ω²r = -2ζω va`, whose particular solution is the constant `-2ζ va / ω`. What is
406
+ * left after subtracting it is the *unforced* damped oscillator, which is the thing that has the
407
+ * closed form this module's whole argument is built on.
408
+ */
409
+ function trailPerUnitSpeed(settings: SpringSettings): number {
410
+ return (-2 * settings.damping) / (2 * Math.PI * settings.frequencyHz);
411
+ }
412
+
413
+ /**
414
+ * The 2x2 that carries `(offset, velocity)` across one substep exactly, written at `at`.
415
+ *
416
+ * It depends only on ω, ζ and the step — none of which change across a march or between axes — so
417
+ * it is built once per link and each substep is four multiplies.
418
+ */
419
+ function transitionOver(
420
+ settings: SpringSettings,
421
+ step: number,
422
+ out: Float64Array,
423
+ at: number,
424
+ ): void {
425
+ const zeta = settings.damping;
426
+ const omega = 2 * Math.PI * settings.frequencyHz;
427
+ const decay = Math.exp(-zeta * omega * step);
428
+ const discriminant = zeta * zeta - 1;
429
+
430
+ let cosine: number;
431
+ let sine: number;
432
+ if (discriminant < -CRITICAL_BAND) {
433
+ /* Underdamped: it rings at a frequency below its own, and `sin(x)/x` carries the velocity. */
434
+ const ringing = omega * Math.sqrt(-discriminant);
435
+ cosine = Math.cos(ringing * step);
436
+ sine = Math.sin(ringing * step) / ringing;
437
+ } else if (discriminant > CRITICAL_BAND) {
438
+ /*
439
+ * Overdamped: the same expressions with the frequency gone imaginary, which is the hyperbolic
440
+ * pair. **Nothing here can overflow, for a reason worth writing down**: the step is the
441
+ * lookback over the step count and the lookback is `-ln(EPSILON)/(ζω)`, so both `ζω·step` and
442
+ * `ω√(ζ²-1)·step` stay under `-ln(EPSILON)` for any settings at all — a stiffer spring shortens
443
+ * its own memory in exact proportion to how fast it moves.
444
+ */
445
+ const sagging = omega * Math.sqrt(discriminant);
446
+ cosine = Math.cosh(sagging * step);
447
+ sine = Math.sinh(sagging * step) / sagging;
448
+ } else {
449
+ /* Critical: the limit of both, taken rather than approached. */
450
+ cosine = 1;
451
+ sine = step;
452
+ }
453
+
454
+ out[at] = decay * (cosine + zeta * omega * sine);
455
+ out[at + 1] = decay * sine;
456
+ out[at + 2] = -decay * omega * omega * sine;
457
+ out[at + 3] = decay * (cosine - zeta * omega * sine);
458
+ }
459
+
460
+ /** One substep of one axis of one link, solved rather than stepped. */
461
+ function advance(
462
+ coefficients: Float64Array,
463
+ from: number,
464
+ trail: number,
465
+ step: number,
466
+ position: Float64Array,
467
+ velocity: Float64Array,
468
+ at: number,
469
+ anchorStart: number,
470
+ anchorEnd: number,
471
+ ): void {
472
+ /*
473
+ * **The anchor is read as moving straight between its two samples**, which is the only
474
+ * approximation left in here and the reason the step still matters. It resolves the *spring's*
475
+ * period, because that is the one thing this function knows; an anchor moving far faster than
476
+ * the spring it drives is read on that grid, and the spring's own response to it is small for
477
+ * exactly the reason it is being undersampled.
478
+ */
479
+ const speed = (anchorEnd - anchorStart) / step;
480
+ const rest = trail * speed;
481
+ const offset = position[at]! - anchorStart - rest;
482
+ const relative = velocity[at]! - speed;
483
+
484
+ position[at] =
485
+ coefficients[from]! * offset + coefficients[from + 1]! * relative + rest + anchorEnd;
486
+ velocity[at] = coefficients[from + 2]! * offset + coefficients[from + 3]! * relative + speed;
487
+ }
488
+
489
+ /**
490
+ * Write a finished position out, held within `limit` metres of its anchor if there is one.
491
+ *
492
+ * **Applied at the end rather than per step, and that is deliberate**: clamping inside the march
493
+ * feeds a position the spring never reached back into its own velocity, which turns a limit into a
494
+ * different spring. This holds the drawn result within reach of the anchor and leaves the motion
495
+ * the settings describe intact.
496
+ */
497
+ function writeClamped(
498
+ limit: number | undefined,
499
+ position: Float64Array,
500
+ from: number,
501
+ anchorX: number,
502
+ anchorY: number,
503
+ anchorZ: number,
504
+ out: Float32Array,
505
+ at: number,
506
+ ): void {
507
+ const dx = position[from]! - anchorX;
508
+ const dy = position[from + 1]! - anchorY;
509
+ const dz = position[from + 2]! - anchorZ;
510
+
511
+ let scale = 1;
512
+ if (limit !== undefined && limit >= 0) {
513
+ const lag = Math.hypot(dx, dy, dz);
514
+ if (lag > limit) scale = limit / lag;
515
+ }
516
+
517
+ out[at] = anchorX + dx * scale;
518
+ out[at + 1] = anchorY + dy * scale;
519
+ out[at + 2] = anchorZ + dz * scale;
520
+ }
@@ -0,0 +1,181 @@
1
+ import { blendPoses } from './blend.ts';
2
+ import type { BlendTree } from './blendTree.ts';
3
+ import type { Pose } from './pose.ts';
4
+ import { createPose } from './pose.ts';
5
+
6
+ /**
7
+ * States over blend trees, and crossfaded transitions between them.
8
+ *
9
+ * **`advance` takes the step rather than reading a clock**, which is the same contract
10
+ * `sampleClip` and `BlendTree` keep and the last layer that could have broken it. A state machine
11
+ * is where an engine most naturally reaches for `performance.now`, because a transition has a
12
+ * duration and a duration wants a clock — and one here would mean a recorded run played back a
13
+ * different pose on a different machine.
14
+ *
15
+ * The conditions are predicates over parameters the consumer names, for the reason the tree gives
16
+ * about a game's own verbs. The engine decides *when a fade completes*, not what a character is
17
+ * doing.
18
+ */
19
+
20
+ export interface AnimationState {
21
+ readonly name: string;
22
+ readonly tree: BlendTree;
23
+ }
24
+
25
+ export interface AnimationTransition {
26
+ readonly from: string;
27
+ readonly to: string;
28
+ readonly durationSec: number;
29
+ /** The consumer's own predicate over the consumer's own parameters. */
30
+ readonly when: (parameters: Readonly<Record<string, number>>) => boolean;
31
+ }
32
+
33
+ export class AnimationStateMachine {
34
+ private readonly states = new Map<string, AnimationState>();
35
+ private readonly parameters: Record<string, number> = {};
36
+
37
+ private active: AnimationState;
38
+ /** The state being left while a fade runs, or null when none is. */
39
+ private leaving: AnimationState | null = null;
40
+ private fadeElapsed = 0;
41
+ private fadeDuration = 0;
42
+
43
+ /** One per side of a fade, claimed at construction so `evaluate` allocates nothing. */
44
+ private readonly fromPose: Pose;
45
+ private readonly toPose: Pose;
46
+
47
+ /**
48
+ * Each state's own elapsed time.
49
+ *
50
+ * Per state rather than one for the machine, so a looping clip is not restarted every time
51
+ * something else changes — and so a state re-entered later resumes where its own clip was rather
52
+ * than wherever the machine happened to be.
53
+ */
54
+ private readonly elapsed = new Map<string, number>();
55
+
56
+ /**
57
+ * @param bind The bind pose, or omitted for one whose channels start at rest.
58
+ *
59
+ * The same reason `BlendTree` takes one: a crossfade calls `blendPoses`, which interpolates
60
+ * every channel, so without a bind pose the two sides of a fade start at zero translation and
61
+ * the figure folds toward its own origin for the length of the transition.
62
+ */
63
+ constructor(
64
+ states: readonly AnimationState[],
65
+ private readonly transitions: readonly AnimationTransition[],
66
+ jointCount: number,
67
+ bind?: Pose,
68
+ ) {
69
+ const first = states[0];
70
+ if (first === undefined) throw new Error('AnimationStateMachine: needs at least one state');
71
+ for (const state of states) {
72
+ this.states.set(state.name, state);
73
+ this.elapsed.set(state.name, 0);
74
+ }
75
+ /*
76
+ * Checked here rather than met at the moment a transition fires. A transition naming a state
77
+ * that does not exist is a configuration error with exactly one correct outcome, and finding
78
+ * it at construction is the difference between a message naming the state and a character that
79
+ * silently never leaves an animation.
80
+ */
81
+ for (const transition of transitions) {
82
+ for (const name of [transition.from, transition.to]) {
83
+ if (!this.states.has(name)) {
84
+ throw new Error(
85
+ `AnimationStateMachine: a transition names state "${name}", which was not given`,
86
+ );
87
+ }
88
+ }
89
+ }
90
+ this.active = first;
91
+ this.fromPose = createPose(jointCount);
92
+ this.toPose = createPose(jointCount);
93
+ if (bind !== undefined) {
94
+ for (const pose of [this.fromPose, this.toPose]) {
95
+ pose.translation.set(bind.translation.subarray(0, pose.translation.length));
96
+ pose.rotation.set(bind.rotation.subarray(0, pose.rotation.length));
97
+ pose.scale.set(bind.scale.subarray(0, pose.scale.length));
98
+ }
99
+ }
100
+ }
101
+
102
+ get current(): string {
103
+ return this.active.name;
104
+ }
105
+
106
+ /** Whether a crossfade is running. A caller wanting to gate input on one asks this. */
107
+ get transitioning(): boolean {
108
+ return this.leaving !== null;
109
+ }
110
+
111
+ /**
112
+ * Set a parameter the transitions read.
113
+ *
114
+ * Unlike `BlendTree.set` this accepts any name: the predicates are the consumer's own functions
115
+ * and this class cannot know which keys they read. The tree below it still refuses a name no node
116
+ * declares, which is where a typo that matters is caught.
117
+ */
118
+ set(parameter: string, value: number): void {
119
+ this.parameters[parameter] = value;
120
+ }
121
+
122
+ /**
123
+ * Advance by a caller-supplied step: the fade, the active state's clock, and any transition
124
+ * whose condition now holds.
125
+ */
126
+ advance(dtSec: number): void {
127
+ if (this.leaving === null) this.start();
128
+ this.elapsed.set(this.active.name, (this.elapsed.get(this.active.name) ?? 0) + dtSec);
129
+
130
+ if (this.leaving === null) return;
131
+ this.elapsed.set(this.leaving.name, (this.elapsed.get(this.leaving.name) ?? 0) + dtSec);
132
+ /*
133
+ * **The frame that triggers a transition advances its fade too.** Starting the fade at zero
134
+ * and leaving it there until the next call is a one-frame stall at the top of every
135
+ * transition — invisible at 60 Hz on a long fade and a whole transition on a short one, which
136
+ * is the case a fade is shortest for.
137
+ */
138
+ this.fadeElapsed += dtSec;
139
+ /*
140
+ * A running fade completes; it is not re-evaluated against the condition that started it. A
141
+ * transition interruptible by its own trigger would stutter whenever that parameter sat on its
142
+ * threshold — which is exactly where a speed parameter spends its time.
143
+ */
144
+ if (this.fadeElapsed >= this.fadeDuration) this.leaving = null;
145
+ }
146
+
147
+ /** Take the first transition out of the active state whose condition holds. */
148
+ private start(): void {
149
+ for (const transition of this.transitions) {
150
+ if (transition.from !== this.active.name) continue;
151
+ if (!transition.when(this.parameters)) continue;
152
+ const next = this.states.get(transition.to);
153
+ if (next === undefined) continue;
154
+
155
+ this.leaving = this.active;
156
+ this.active = next;
157
+ this.elapsed.set(next.name, 0);
158
+ this.fadeElapsed = 0;
159
+ this.fadeDuration = transition.durationSec;
160
+ /*
161
+ * A zero-duration transition is a switch. Ended here rather than divided by below, because
162
+ * the division is what would produce the NaN — and a NaN weight reaches the palette and
163
+ * takes every vertex it touches.
164
+ */
165
+ if (this.fadeDuration <= 0) this.leaving = null;
166
+ return;
167
+ }
168
+ }
169
+
170
+ /** The current pose, crossfaded if a transition is running. Allocates nothing. */
171
+ evaluate(out: Pose): void {
172
+ const activeTime = this.elapsed.get(this.active.name) ?? 0;
173
+ if (this.leaving === null) {
174
+ this.active.tree.evaluate(activeTime, out);
175
+ return;
176
+ }
177
+ this.leaving.tree.evaluate(this.elapsed.get(this.leaving.name) ?? 0, this.fromPose);
178
+ this.active.tree.evaluate(activeTime, this.toPose);
179
+ blendPoses(this.fromPose, this.toPose, this.fadeElapsed / this.fadeDuration, out);
180
+ }
181
+ }