@vune-ui/animation 0.1.20

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.
Files changed (68) hide show
  1. package/ARCHITECTURE.md +470 -0
  2. package/CHANGELOG.md +88 -0
  3. package/LICENSE +21 -0
  4. package/PERFORMANCE.md +151 -0
  5. package/README.md +630 -0
  6. package/dist/index.d.ts +474 -0
  7. package/dist/src/canvas/index.d.ts +15 -0
  8. package/dist/src/canvas/index.js +67 -0
  9. package/dist/src/constraints/index.d.ts +33 -0
  10. package/dist/src/constraints/index.js +346 -0
  11. package/dist/src/core/bezier.js +51 -0
  12. package/dist/src/core/composition.js +17 -0
  13. package/dist/src/core/controls.js +22 -0
  14. package/dist/src/core/default-engine.js +20 -0
  15. package/dist/src/core/easing.js +58 -0
  16. package/dist/src/core/engine.js +1031 -0
  17. package/dist/src/core/frame-budget.js +30 -0
  18. package/dist/src/core/index.d.ts +43 -0
  19. package/dist/src/core/index.js +17 -0
  20. package/dist/src/core/js-spring-batch.js +57 -0
  21. package/dist/src/core/kinetics.js +140 -0
  22. package/dist/src/core/math.js +20 -0
  23. package/dist/src/core/motion-value.js +53 -0
  24. package/dist/src/core/planner.js +72 -0
  25. package/dist/src/core/specs.js +70 -0
  26. package/dist/src/dom/index.d.ts +41 -0
  27. package/dist/src/dom/index.js +364 -0
  28. package/dist/src/gesture/index.d.ts +66 -0
  29. package/dist/src/gesture/index.js +376 -0
  30. package/dist/src/index.js +53 -0
  31. package/dist/src/interpolate/color.js +223 -0
  32. package/dist/src/interpolate/css.d.ts +13 -0
  33. package/dist/src/interpolate/css.js +34 -0
  34. package/dist/src/interpolate/index.d.ts +13 -0
  35. package/dist/src/interpolate/index.js +55 -0
  36. package/dist/src/interpolate/transform.js +247 -0
  37. package/dist/src/layout/index.d.ts +56 -0
  38. package/dist/src/layout/index.js +485 -0
  39. package/dist/src/material/index.d.ts +9 -0
  40. package/dist/src/material/index.js +70 -0
  41. package/dist/src/path/index.d.ts +37 -0
  42. package/dist/src/path/index.js +527 -0
  43. package/dist/src/render/frame-batcher.js +52 -0
  44. package/dist/src/scroll/index.d.ts +55 -0
  45. package/dist/src/scroll/index.js +233 -0
  46. package/dist/src/timeline/index.d.ts +147 -0
  47. package/dist/src/timeline/index.js +849 -0
  48. package/dist/src/transition/index.d.ts +88 -0
  49. package/dist/src/transition/index.js +369 -0
  50. package/dist/src/wasm/index.d.ts +29 -0
  51. package/dist/src/wasm/index.js +8 -0
  52. package/dist/src/wasm/loader.js +55 -0
  53. package/dist/src/wasm/shared-wasm-spring-batch.js +52 -0
  54. package/dist/src/wasm/wasm-spring-batch.js +52 -0
  55. package/dist/src/webgl/index.d.ts +22 -0
  56. package/dist/src/webgl/index.js +94 -0
  57. package/dist/src/webgpu/index.d.ts +35 -0
  58. package/dist/src/webgpu/index.js +73 -0
  59. package/dist/src/webgpu/spring-batch.js +218 -0
  60. package/dist/src/worker/index.d.ts +17 -0
  61. package/dist/src/worker/index.js +1 -0
  62. package/dist/src/worker/shared-spring-worker.js +218 -0
  63. package/dist/src/worker/shared-worker.js +75 -0
  64. package/dist/wasm/kernel-scalar.wasm +0 -0
  65. package/dist/wasm/kernel-shared-scalar.wasm +0 -0
  66. package/dist/wasm/kernel-shared-simd.wasm +0 -0
  67. package/dist/wasm/kernel-simd.wasm +0 -0
  68. package/package.json +113 -0
@@ -0,0 +1,1031 @@
1
+ import { evaluateCompiledEasing } from './easing.js';
2
+ import { AnimationControls, deferredControls } from './controls.js';
3
+ import { FrameBudgetGovernor } from './frame-budget.js';
4
+ import { JsSpringBatch } from './js-spring-batch.js';
5
+ import { resolveMotionPlan } from './planner.js';
6
+ import { inertia as inertiaSpec, projectDecayTarget, nearestBound, clampToBounds, stepDecay, stepDampedSpring } from './kinetics.js';
7
+
8
+ const DEFAULT_EPSILON = 0.001;
9
+ const DEFAULT_VELOCITY_EPSILON = 0.01;
10
+
11
+ function nowMs() {
12
+ return globalThis.performance?.now?.() ?? Date.now();
13
+ }
14
+
15
+ function defaultRaf(callback) {
16
+ if (typeof globalThis.requestAnimationFrame === 'function') return globalThis.requestAnimationFrame(callback);
17
+ return setTimeout(() => callback(nowMs()), 16);
18
+ }
19
+
20
+ function defaultCancelRaf(id) {
21
+ if (typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(id);
22
+ else clearTimeout(id);
23
+ }
24
+
25
+ export class MotionEngine {
26
+ constructor({
27
+ autoStart = true,
28
+ wasm = 'auto',
29
+ wasmThreshold = 256,
30
+ maxWasmMotions = 65536,
31
+ worker = 'auto',
32
+ workerThreshold = 4096,
33
+ gpu = 'auto',
34
+ gpuThreshold = 4096,
35
+ gpuDevice = null,
36
+ autoWorkerScheduler = true,
37
+ adaptiveBackends = true,
38
+ frameBudgetMs = 8,
39
+ respectReducedMotion = true,
40
+ } = {}) {
41
+ this.autoStart = autoStart;
42
+ this.wasmMode = wasm;
43
+ this.wasmThreshold = Math.max(1, Math.floor(wasmThreshold));
44
+ this.maxWasmMotions = Math.max(1, Math.floor(maxWasmMotions));
45
+ this.workerMode = worker;
46
+ this.workerThreshold = Math.max(1, Math.floor(workerThreshold));
47
+ this.gpuMode = gpu;
48
+ this.gpuThreshold = Math.max(1, Math.floor(gpuThreshold));
49
+ this.gpuDevice = gpuDevice;
50
+ this.autoWorkerScheduler = autoWorkerScheduler;
51
+ this.adaptiveBackends = adaptiveBackends;
52
+ this.respectReducedMotion = respectReducedMotion;
53
+ this.frameBudget = frameBudgetMs === false ? null : new FrameBudgetGovernor({ budgetMs: frameBudgetMs });
54
+
55
+ this.batch = new JsSpringBatch(256);
56
+ this.springs = [];
57
+ this.activeSpringCount = 0;
58
+ this.timings = [];
59
+ this.kinetics = [];
60
+ this.kineticScratch = { position: 0, velocity: 0 };
61
+ this.byValue = new Map();
62
+ this.drivers = new Set();
63
+
64
+ this.running = false;
65
+ this.frameId = null;
66
+ this.lastTime = null;
67
+ this.disposed = false;
68
+
69
+ this.wasmBatch = null;
70
+ this.wasmPromise = null;
71
+ this.workerBackend = null;
72
+ this.workerPromise = null;
73
+ this.workerUnavailable = false;
74
+ this.gpuBackend = null;
75
+ this.gpuPromise = null;
76
+ this.gpuUnavailable = false;
77
+
78
+ // While an asynchronous backend is stepping spring memory, mutable
79
+ // commands that touch an in-flight slot are buffered until that frame
80
+ // completes. Adds beyond the submitted count can be initialized now.
81
+ this.workerFrameInFlight = false;
82
+ this.gpuFrameInFlight = false;
83
+ this.inFlightSpringCount = 0;
84
+ this.pendingSpringSync = new Set();
85
+ this.deferredSpringRemovals = new Set();
86
+ this.asyncStepChain = Promise.resolve();
87
+
88
+ this.stats = {
89
+ frames: 0,
90
+ syncFrames: 0,
91
+ asyncFrames: 0,
92
+ workerFrames: 0,
93
+ workerFailures: 0,
94
+ gpuFrames: 0,
95
+ gpuFailures: 0,
96
+ promotedToWasm: false,
97
+ promotedToWorker: false,
98
+ promotedToGpu: false,
99
+ backend: 'js',
100
+ lastDtMs: 0,
101
+ lastStepWallMs: 0,
102
+ lastMainThreadMs: 0,
103
+ emaMainThreadMs: 0,
104
+ budgetPressure: 0,
105
+ budgetLevel: 'idle',
106
+ effectiveWasmThreshold: this.wasmThreshold,
107
+ effectiveWorkerThreshold: this.workerThreshold,
108
+ effectiveGpuThreshold: this.gpuThreshold,
109
+ activeSprings: 0,
110
+ activeKinetics: 0,
111
+ activeDrivers: 0,
112
+ pendingMutations: 0,
113
+ };
114
+
115
+ if (wasm === true) this.prepareWasm().catch(() => {});
116
+ if (worker === true) this.prepareWorker().catch(() => {});
117
+ if (gpu === true) this.prepareGpu().catch(() => {});
118
+ }
119
+
120
+ addDriver(driver) {
121
+ if (this.disposed) throw new Error('MotionEngine is disposed.');
122
+ if (!driver || typeof driver.step !== 'function') throw new TypeError('MotionEngine driver requires a step(dtMs) method.');
123
+ this.drivers.add(driver);
124
+ this.#updateThresholdStats();
125
+ this.#ensureRunning();
126
+ return () => this.removeDriver(driver);
127
+ }
128
+
129
+ removeDriver(driver) {
130
+ const removed = this.drivers.delete(driver);
131
+ if (removed) {
132
+ this.#updateThresholdStats();
133
+ if (!this.#hasWork()) this.#stopLoop();
134
+ }
135
+ return removed;
136
+ }
137
+
138
+ prefersReducedMotion() {
139
+ return this.respectReducedMotion
140
+ && typeof globalThis.matchMedia === 'function'
141
+ && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
142
+ }
143
+
144
+ async prepareWasm() {
145
+ if (this.wasmMode === false || this.disposed) return null;
146
+ if (this.wasmBatch) return this.wasmBatch;
147
+ if (this.wasmPromise) return this.wasmPromise;
148
+ this.wasmPromise = import('../wasm/wasm-spring-batch.js')
149
+ .then(({ WasmSpringBatch }) => WasmSpringBatch.create(this.maxWasmMotions))
150
+ .then((batch) => {
151
+ if (this.disposed) return null;
152
+ this.wasmBatch = batch;
153
+ return batch;
154
+ })
155
+ .finally(() => {
156
+ this.wasmPromise = null;
157
+ });
158
+ return this.wasmPromise;
159
+ }
160
+
161
+ async prepareWorker() {
162
+ if (this.workerMode === false || this.workerUnavailable || this.disposed) return null;
163
+ if (this.workerBackend) return this.workerBackend;
164
+ if (this.workerPromise) return this.workerPromise;
165
+ this.workerPromise = import('../worker/shared-spring-worker.js')
166
+ .then(({ SharedSpringWorkerBackend }) => SharedSpringWorkerBackend.create(this.maxWasmMotions))
167
+ .then((backend) => {
168
+ if (this.disposed) {
169
+ backend.dispose();
170
+ return null;
171
+ }
172
+ this.workerBackend = backend;
173
+ return backend;
174
+ })
175
+ .catch((error) => {
176
+ if (this.workerMode === true) throw error;
177
+ this.workerUnavailable = true;
178
+ return null;
179
+ })
180
+ .finally(() => {
181
+ this.workerPromise = null;
182
+ });
183
+ return this.workerPromise;
184
+ }
185
+
186
+ async prepareGpu() {
187
+ if (this.gpuMode === false || this.gpuUnavailable || this.disposed) return null;
188
+ if (this.gpuBackend) return this.gpuBackend;
189
+ if (this.gpuPromise) return this.gpuPromise;
190
+ this.gpuPromise = import('../webgpu/spring-batch.js')
191
+ .then(({ WebGPUSpringBatch }) => WebGPUSpringBatch.create(this.maxWasmMotions, this.gpuDevice))
192
+ .then((backend) => {
193
+ if (this.disposed) {
194
+ backend.dispose();
195
+ return null;
196
+ }
197
+ this.gpuBackend = backend;
198
+ return backend;
199
+ })
200
+ .catch((error) => {
201
+ if (this.gpuMode === true) throw error;
202
+ this.gpuUnavailable = true;
203
+ return null;
204
+ })
205
+ .finally(() => {
206
+ this.gpuPromise = null;
207
+ });
208
+ return this.gpuPromise;
209
+ }
210
+
211
+ #effectiveWasmThreshold() {
212
+ if (!this.adaptiveBackends || !this.frameBudget) return this.wasmThreshold;
213
+ return this.frameBudget.wasmThreshold(this.wasmThreshold, this.activeSpringCount);
214
+ }
215
+
216
+ #effectiveWorkerThreshold() {
217
+ if (!this.adaptiveBackends || !this.frameBudget) return this.workerThreshold;
218
+ return this.frameBudget.workerThreshold(this.workerThreshold, this.activeSpringCount);
219
+ }
220
+
221
+ #effectiveGpuThreshold() {
222
+ if (!this.adaptiveBackends || !this.frameBudget) return this.gpuThreshold;
223
+ return this.frameBudget.workerThreshold(this.gpuThreshold, this.activeSpringCount);
224
+ }
225
+
226
+ #updateThresholdStats() {
227
+ this.stats.effectiveWasmThreshold = this.#effectiveWasmThreshold();
228
+ this.stats.effectiveWorkerThreshold = this.#effectiveWorkerThreshold();
229
+ this.stats.effectiveGpuThreshold = this.#effectiveGpuThreshold();
230
+ this.stats.activeSprings = this.activeSpringCount;
231
+ this.stats.activeKinetics = this.kinetics.length;
232
+ this.stats.activeDrivers = this.drivers.size;
233
+ this.stats.pendingMutations = this.pendingSpringSync.size + this.deferredSpringRemovals.size;
234
+ }
235
+
236
+ maybePrepareWasm() {
237
+ if (this.wasmMode === false || this.wasmBatch || this.wasmPromise || this.disposed) return;
238
+ if (this.activeSpringCount < this.#effectiveWasmThreshold()) return;
239
+ this.prepareWasm().catch(() => {});
240
+ }
241
+
242
+ maybePromoteToWasm() {
243
+ if (!this.wasmBatch || this.batch.kind !== 'js' || this.workerFrameInFlight) return false;
244
+ const threshold = this.#effectiveWasmThreshold();
245
+ if (this.activeSpringCount < threshold) return false;
246
+ if (this.springs.length > this.wasmBatch.capacity) return false;
247
+ this.batch.ensureCapacity(this.springs.length);
248
+ this.batch.copyInto(this.wasmBatch, this.springs.length);
249
+ this.batch = this.wasmBatch;
250
+ this.stats.promotedToWasm = true;
251
+ this.stats.backend = `wasm-${this.wasmBatch.variant}`;
252
+ this.#updateThresholdStats();
253
+ return true;
254
+ }
255
+
256
+ maybePrepareWorker() {
257
+ if (this.workerMode === false || this.workerUnavailable || this.workerBackend || this.workerPromise || this.disposed) return;
258
+ if (this.activeSpringCount < this.#effectiveWorkerThreshold()) return;
259
+ this.prepareWorker().catch(() => {});
260
+ }
261
+
262
+ maybePrepareGpu() {
263
+ if (this.gpuMode === false || this.gpuUnavailable || this.gpuBackend || this.gpuPromise || this.disposed) return;
264
+ if (this.activeSpringCount < this.#effectiveGpuThreshold()) return;
265
+ this.prepareGpu().catch(() => {});
266
+ }
267
+
268
+ maybePromoteToWorker() {
269
+ if (!this.workerBackend || this.batch === this.workerBackend || this.batch.kind === 'webgpu' || this.workerFrameInFlight || this.gpuFrameInFlight) return false;
270
+ if (this.activeSpringCount < this.#effectiveWorkerThreshold()) return false;
271
+ if (this.springs.length > this.workerBackend.capacity) return false;
272
+ this.batch.ensureCapacity(this.springs.length);
273
+ this.batch.copyInto(this.workerBackend, this.springs.length);
274
+ this.batch = this.workerBackend;
275
+ this.stats.promotedToWorker = true;
276
+ this.stats.backend = `shared-wasm-${this.workerBackend.variant}`;
277
+ this.#updateThresholdStats();
278
+ return true;
279
+ }
280
+
281
+ maybePromoteToGpu() {
282
+ if (!this.gpuBackend || this.batch === this.gpuBackend || this.workerFrameInFlight || this.gpuFrameInFlight) return false;
283
+ if (this.activeSpringCount < this.#effectiveGpuThreshold()) return false;
284
+ if (this.springs.length > this.gpuBackend.capacity) return false;
285
+ this.batch.ensureCapacity(this.springs.length);
286
+ this.batch.copyInto(this.gpuBackend, this.springs.length);
287
+ this.batch = this.gpuBackend;
288
+ this.stats.promotedToGpu = true;
289
+ this.stats.backend = 'webgpu-compute';
290
+ this.#updateThresholdStats();
291
+ return true;
292
+ }
293
+
294
+ #demoteGpuToJs() {
295
+ if (this.batch.kind !== 'webgpu') return false;
296
+ const next = new JsSpringBatch(Math.max(256, this.springs.length));
297
+ this.batch.copyInto(next, this.springs.length);
298
+ this.batch = next;
299
+ this.stats.backend = 'js';
300
+ this.#updateThresholdStats();
301
+ return true;
302
+ }
303
+
304
+ #interruptDriversForValue(value, status = 'interrupted') {
305
+ if (this.drivers.size === 0) return;
306
+ for (const driver of this.drivers) {
307
+ if (driver.owns?.(value)) driver.interruptValue?.(value, status);
308
+ }
309
+ }
310
+
311
+ animate(value, to, requestedSpec) {
312
+ if (this.disposed) throw new Error('MotionEngine is disposed.');
313
+ if (!Number.isFinite(to)) throw new TypeError('animate() target must be a finite number.');
314
+ this.#interruptDriversForValue(value);
315
+ const from = value.get();
316
+ const plan = resolveMotionPlan(requestedSpec, from, to);
317
+
318
+ if (this.prefersReducedMotion()) {
319
+ this.stop(value, 'interrupted');
320
+ value.set(to, 0);
321
+ const d = deferredControls();
322
+ d.settle({ status: 'finished', value: to, reducedMotion: true });
323
+ return new AnimationControls(() => {}, () => {}, d.finished);
324
+ }
325
+
326
+ if (plan.route === 'spring') return this.#animateSpring(value, to, plan);
327
+ if (plan.route === 'timing') return this.#animateTiming(value, to, plan);
328
+ throw new TypeError(`Unknown motion plan route: ${plan.route}`);
329
+ }
330
+
331
+ animateVelocity(value, requestedSpec) {
332
+ if (this.disposed) throw new Error('MotionEngine is disposed.');
333
+ this.#interruptDriversForValue(value);
334
+ const spec = requestedSpec?.kind === 'inertia'
335
+ ? requestedSpec
336
+ : requestedSpec?.kind === 'decay'
337
+ ? requestedSpec
338
+ : inertiaSpec(requestedSpec ?? {});
339
+
340
+ if (this.prefersReducedMotion()) {
341
+ this.stop(value, 'interrupted');
342
+ const velocity = Number.isFinite(spec.velocity) ? spec.velocity : value.getVelocity();
343
+ const projected = projectDecayTarget(value.get(), velocity, spec);
344
+ const finalValue = spec.kind === 'inertia'
345
+ ? clampToBounds(projected, spec.min, spec.max)
346
+ : projected;
347
+ value.set(finalValue, 0);
348
+ const d = deferredControls();
349
+ d.settle({ status: 'finished', value: finalValue, reducedMotion: true });
350
+ return new AnimationControls(() => {}, () => {}, d.finished);
351
+ }
352
+
353
+ return this.#animateKinetic(value, spec);
354
+ }
355
+
356
+ #animateKinetic(value, requestedSpec) {
357
+ const existing = this.byValue.get(value);
358
+ if (existing) this.#removeAnimation(existing, 'interrupted');
359
+
360
+ const spec = requestedSpec;
361
+ const from = value.get();
362
+ const sourceVelocity = Number.isFinite(spec.velocity) ? spec.velocity : value.getVelocity();
363
+ const projectedTarget = projectDecayTarget(from, sourceVelocity, spec);
364
+ const initialVelocity = spec.timeConstant > 0
365
+ ? (projectedTarget - from) / spec.timeConstant
366
+ : sourceVelocity * spec.power;
367
+ const outside = spec.kind === 'inertia' ? nearestBound(from, spec.min, spec.max) : null;
368
+ const finalTarget = spec.kind === 'inertia'
369
+ ? clampToBounds(projectedTarget, spec.min, spec.max)
370
+ : projectedTarget;
371
+ const index = this.kinetics.length;
372
+
373
+ const animation = {
374
+ type: 'kinetic',
375
+ index,
376
+ kind: spec.kind,
377
+ value,
378
+ mode: outside == null ? 'decay' : 'spring',
379
+ position: from,
380
+ velocity: initialVelocity,
381
+ projectedTarget,
382
+ finalTarget: outside ?? finalTarget,
383
+ timeConstant: spec.timeConstant,
384
+ restSpeed: spec.restSpeed,
385
+ restDelta: spec.kind === 'inertia' ? spec.restDelta : 0.5,
386
+ min: spec.kind === 'inertia' ? spec.min : -Infinity,
387
+ max: spec.kind === 'inertia' ? spec.max : Infinity,
388
+ bounceOmega: spec.kind === 'inertia' ? spec.bounceOmega : 0,
389
+ bounceDampingRatio: spec.kind === 'inertia' ? spec.bounceDampingRatio : 1,
390
+ controlState: deferredControls(),
391
+ };
392
+
393
+ this.kinetics.push(animation);
394
+ this.byValue.set(value, animation);
395
+ value._commit(from, initialVelocity);
396
+ this.#updateThresholdStats();
397
+ this.#ensureRunning();
398
+ return this.#controlsFor(animation);
399
+ }
400
+
401
+ #slotIsAsyncLocked(index) {
402
+ return ((this.workerFrameInFlight && this.batch.kind === 'worker-wasm')
403
+ || (this.gpuFrameInFlight && this.batch.kind === 'webgpu'))
404
+ && index >= 0
405
+ && index < this.inFlightSpringCount;
406
+ }
407
+
408
+ #syncSpringParameters(animation) {
409
+ if (!animation.active || animation.index < 0) return;
410
+ const index = animation.index;
411
+ this.batch.targets[index] = animation.target;
412
+ this.batch.omegas[index] = animation.omega;
413
+ this.batch.dampingRatios[index] = animation.dampingRatio;
414
+ if (Number.isFinite(animation.pendingVelocityOverride)) {
415
+ this.batch.velocities[index] = animation.pendingVelocityOverride;
416
+ animation.pendingVelocityOverride = undefined;
417
+ }
418
+ }
419
+
420
+ #animateSpring(value, to, plan) {
421
+ const existing = this.byValue.get(value);
422
+ if (existing?.type === 'spring') {
423
+ existing.controlState.settle({ status: 'interrupted', value: value.get() });
424
+ existing.controlState = deferredControls();
425
+ existing.target = to;
426
+ const blendDurationMs = Math.max(0, plan.blendDurationMs || 0);
427
+ if (blendDurationMs > 0
428
+ && (existing.omega !== plan.omega || existing.dampingRatio !== plan.dampingRatio)) {
429
+ existing.blendFromOmega = existing.omega;
430
+ existing.blendFromDampingRatio = existing.dampingRatio;
431
+ existing.blendToOmega = plan.omega;
432
+ existing.blendToDampingRatio = plan.dampingRatio;
433
+ existing.blendElapsedMs = 0;
434
+ existing.blendDurationMs = blendDurationMs;
435
+ } else {
436
+ existing.omega = plan.omega;
437
+ existing.dampingRatio = plan.dampingRatio;
438
+ existing.blendDurationMs = 0;
439
+ existing.blendElapsedMs = 0;
440
+ }
441
+ if (Number.isFinite(plan.initialVelocity)) existing.pendingVelocityOverride = plan.initialVelocity;
442
+
443
+ if (this.#slotIsAsyncLocked(existing.index)) this.pendingSpringSync.add(existing);
444
+ else this.#syncSpringParameters(existing);
445
+
446
+ this.#updateThresholdStats();
447
+ this.#ensureRunning();
448
+ return this.#controlsFor(existing);
449
+ }
450
+
451
+ if (existing) this.#removeAnimation(existing, 'interrupted');
452
+
453
+ const index = this.springs.length;
454
+ this.batch.ensureCapacity(index + 1);
455
+ const initialVelocity = Number.isFinite(plan.initialVelocity) ? plan.initialVelocity : value.getVelocity();
456
+ const animation = {
457
+ type: 'spring',
458
+ value,
459
+ index,
460
+ active: true,
461
+ pendingRemoval: null,
462
+ target: to,
463
+ omega: plan.omega,
464
+ dampingRatio: plan.dampingRatio,
465
+ blendFromOmega: plan.omega,
466
+ blendFromDampingRatio: plan.dampingRatio,
467
+ blendToOmega: plan.omega,
468
+ blendToDampingRatio: plan.dampingRatio,
469
+ blendElapsedMs: 0,
470
+ blendDurationMs: 0,
471
+ pendingVelocityOverride: undefined,
472
+ controlState: deferredControls(),
473
+ epsilon: DEFAULT_EPSILON,
474
+ velocityEpsilon: DEFAULT_VELOCITY_EPSILON,
475
+ };
476
+ this.springs.push(animation);
477
+ this.activeSpringCount += 1;
478
+ this.byValue.set(value, animation);
479
+ this.batch.positions[index] = value.get();
480
+ this.batch.velocities[index] = initialVelocity;
481
+ this.batch.targets[index] = to;
482
+ this.batch.omegas[index] = plan.omega;
483
+ this.batch.dampingRatios[index] = plan.dampingRatio;
484
+
485
+ this.maybePrepareWasm();
486
+ this.maybePromoteToWasm();
487
+ this.maybePrepareWorker();
488
+ this.maybePrepareGpu();
489
+ this.#updateThresholdStats();
490
+ this.#ensureRunning();
491
+ return this.#controlsFor(animation);
492
+ }
493
+
494
+ #animateTiming(value, to, plan) {
495
+ const existing = this.byValue.get(value);
496
+ if (existing) this.#removeAnimation(existing, 'interrupted');
497
+
498
+ if (plan.durationMs === 0) {
499
+ value.set(to, 0);
500
+ const d = deferredControls();
501
+ d.settle({ status: 'finished', value: to });
502
+ return new AnimationControls(() => {}, () => {}, d.finished);
503
+ }
504
+
505
+ const index = this.timings.length;
506
+ const animation = {
507
+ type: 'timing',
508
+ index,
509
+ value,
510
+ from: value.get(),
511
+ to,
512
+ durationMs: plan.durationMs,
513
+ elapsedMs: 0,
514
+ easing: plan.easing,
515
+ previous: value.get(),
516
+ startAfterFrame: (this.workerFrameInFlight || this.gpuFrameInFlight) ? this.stats.frames + 1 : this.stats.frames,
517
+ controlState: deferredControls(),
518
+ };
519
+ this.timings.push(animation);
520
+ this.byValue.set(value, animation);
521
+ this.#ensureRunning();
522
+ return this.#controlsFor(animation);
523
+ }
524
+
525
+ #controlsFor(animation) {
526
+ const state = animation.controlState;
527
+ return new AnimationControls(
528
+ () => {
529
+ if (this.byValue.get(animation.value) === animation && animation.controlState === state) {
530
+ this.#removeAnimation(animation, 'cancelled');
531
+ }
532
+ },
533
+ () => {
534
+ if (this.byValue.get(animation.value) !== animation || animation.controlState !== state) return;
535
+ if (animation.type === 'spring') animation.value.set(animation.target, 0);
536
+ else if (animation.type === 'timing') animation.value.set(animation.to, 0);
537
+ else animation.value.set(animation.finalTarget, 0);
538
+ this.#removeAnimation(animation, 'finished');
539
+ },
540
+ state.finished,
541
+ );
542
+ }
543
+
544
+ stop(value, status = 'cancelled') {
545
+ this.#interruptDriversForValue(value, status);
546
+ const existing = this.byValue.get(value);
547
+ if (existing) this.#removeAnimation(existing, status);
548
+ }
549
+
550
+ #logicalRemoveSpring(animation, status) {
551
+ if (!animation.active) return;
552
+ animation.active = false;
553
+ animation.pendingRemoval = status;
554
+ this.activeSpringCount -= 1;
555
+ if (this.byValue.get(animation.value) === animation) this.byValue.delete(animation.value);
556
+ animation.controlState.settle({ status, value: animation.value.get() });
557
+ }
558
+
559
+ #physicalRemoveSpring(animation) {
560
+ const index = animation.index;
561
+ if (index < 0 || this.springs[index] !== animation) return;
562
+ const lastIndex = this.springs.length - 1;
563
+ if (index !== lastIndex) {
564
+ const moved = this.springs[lastIndex];
565
+ this.springs[index] = moved;
566
+ moved.index = index;
567
+ for (const key of ['positions', 'velocities', 'targets', 'omegas', 'dampingRatios']) {
568
+ this.batch[key][index] = this.batch[key][lastIndex];
569
+ }
570
+ }
571
+ this.springs.pop();
572
+ animation.index = -1;
573
+ animation.pendingRemoval = null;
574
+ }
575
+
576
+ #removeAnimation(animation, status) {
577
+ if (animation.type === 'spring') {
578
+ if (!animation.active) return;
579
+ if (this.#slotIsAsyncLocked(animation.index)) {
580
+ this.#logicalRemoveSpring(animation, status);
581
+ this.pendingSpringSync.delete(animation);
582
+ this.deferredSpringRemovals.add(animation);
583
+ this.#updateThresholdStats();
584
+ return;
585
+ }
586
+ this.#logicalRemoveSpring(animation, status);
587
+ this.#physicalRemoveSpring(animation);
588
+ } else {
589
+ const list = animation.type === 'timing' ? this.timings : this.kinetics;
590
+ const index = animation.index;
591
+ if (index >= 0 && list[index] === animation) {
592
+ const last = list.pop();
593
+ if (last !== animation && last) {
594
+ list[index] = last;
595
+ last.index = index;
596
+ }
597
+ animation.index = -1;
598
+ }
599
+ if (this.byValue.get(animation.value) === animation) this.byValue.delete(animation.value);
600
+ animation.controlState.settle({ status, value: animation.value.get() });
601
+ }
602
+
603
+ this.#updateThresholdStats();
604
+ if (!this.#hasWork()) this.#stopLoop();
605
+ }
606
+
607
+ #flushDeferredSpringMutations() {
608
+ if (this.deferredSpringRemovals.size > 0) {
609
+ for (const animation of this.deferredSpringRemovals) this.#physicalRemoveSpring(animation);
610
+ this.deferredSpringRemovals.clear();
611
+ }
612
+ if (this.pendingSpringSync.size > 0) {
613
+ for (const animation of this.pendingSpringSync) {
614
+ if (animation.active) this.#syncSpringParameters(animation);
615
+ }
616
+ this.pendingSpringSync.clear();
617
+ }
618
+ this.#updateThresholdStats();
619
+ }
620
+
621
+ #restoreBatchFromMotionValues() {
622
+ for (const animation of this.springs) {
623
+ if (!animation.active || animation.index < 0) continue;
624
+ const index = animation.index;
625
+ this.batch.positions[index] = animation.value.get();
626
+ this.batch.velocities[index] = animation.value.getVelocity();
627
+ this.#syncSpringParameters(animation);
628
+ }
629
+ }
630
+
631
+ #prepareStep(dtMs, { preferWorker = false } = {}) {
632
+ if (!Number.isFinite(dtMs) || dtMs <= 0 || this.disposed) return 0;
633
+ const clampedDt = Math.min(dtMs, 250);
634
+ this.stats.frames += 1;
635
+ this.stats.lastDtMs = clampedDt;
636
+ this.maybePrepareWasm();
637
+ this.maybePrepareGpu();
638
+
639
+ if (preferWorker && this.gpuBackend && this.activeSpringCount >= this.#effectiveGpuThreshold()) {
640
+ this.maybePromoteToGpu();
641
+ } else if (preferWorker && this.workerBackend && this.activeSpringCount >= this.#effectiveWorkerThreshold()) {
642
+ this.maybePromoteToWorker();
643
+ } else {
644
+ this.maybePromoteToWasm();
645
+ }
646
+ this.maybePrepareWorker();
647
+ this.maybePrepareGpu();
648
+ this.#updateThresholdStats();
649
+ return clampedDt;
650
+ }
651
+
652
+ #advanceSpringBlends(clampedDt) {
653
+ if (clampedDt <= 0) return;
654
+ for (const animation of this.springs) {
655
+ if (!animation.active || animation.blendDurationMs <= 0) continue;
656
+ animation.blendElapsedMs = Math.min(animation.blendDurationMs, animation.blendElapsedMs + clampedDt);
657
+ const progress = animation.blendDurationMs > 0 ? animation.blendElapsedMs / animation.blendDurationMs : 1;
658
+ animation.omega = animation.blendFromOmega + (animation.blendToOmega - animation.blendFromOmega) * progress;
659
+ animation.dampingRatio = animation.blendFromDampingRatio
660
+ + (animation.blendToDampingRatio - animation.blendFromDampingRatio) * progress;
661
+ if (progress >= 1) {
662
+ animation.omega = animation.blendToOmega;
663
+ animation.dampingRatio = animation.blendToDampingRatio;
664
+ animation.blendDurationMs = 0;
665
+ animation.blendElapsedMs = 0;
666
+ }
667
+ this.#syncSpringParameters(animation);
668
+ }
669
+ }
670
+
671
+ #commitSpringValues() {
672
+ for (let i = this.springs.length - 1; i >= 0; i -= 1) {
673
+ const animation = this.springs[i];
674
+ if (!animation.active) continue;
675
+ const x = this.batch.positions[i];
676
+ const v = this.batch.velocities[i];
677
+ const target = animation.target;
678
+ if (Math.abs(target - x) <= animation.epsilon && Math.abs(v) <= animation.velocityEpsilon) {
679
+ animation.value.set(target, 0);
680
+ this.#removeAnimation(animation, 'finished');
681
+ } else {
682
+ animation.value._commit(x, v);
683
+ }
684
+ }
685
+ }
686
+
687
+ #stepTimings(clampedDt) {
688
+ for (let i = this.timings.length - 1; i >= 0; i -= 1) {
689
+ const animation = this.timings[i];
690
+ if (animation.startAfterFrame > this.stats.frames) continue;
691
+ animation.elapsedMs += clampedDt;
692
+ const progress = Math.min(1, animation.elapsedMs / animation.durationMs);
693
+ const eased = evaluateCompiledEasing(animation.easing, progress);
694
+ const next = animation.from + (animation.to - animation.from) * eased;
695
+ const velocity = clampedDt > 0 ? (next - animation.previous) / (clampedDt / 1000) : 0;
696
+ animation.previous = next;
697
+ animation.value._commit(next, velocity);
698
+ if (progress >= 1) {
699
+ animation.value.set(animation.to, 0);
700
+ this.#removeAnimation(animation, 'finished');
701
+ }
702
+ }
703
+ }
704
+
705
+ #stepKinetics(clampedDt) {
706
+ const dtSeconds = clampedDt / 1000;
707
+ for (let i = this.kinetics.length - 1; i >= 0; i -= 1) {
708
+ const animation = this.kinetics[i];
709
+ let next;
710
+
711
+ if (animation.mode === 'decay') {
712
+ next = stepDecay(animation.position, animation.velocity, dtSeconds, animation.timeConstant, this.kineticScratch);
713
+ animation.position = next.position;
714
+ animation.velocity = next.velocity;
715
+
716
+ if (animation.kind === 'inertia') {
717
+ const crossed = nearestBound(animation.position, animation.min, animation.max);
718
+ if (crossed != null) {
719
+ animation.mode = 'spring';
720
+ animation.finalTarget = crossed;
721
+ }
722
+ }
723
+
724
+ if (animation.mode === 'decay' && Math.abs(animation.velocity) <= animation.restSpeed) {
725
+ animation.position = animation.finalTarget;
726
+ animation.velocity = 0;
727
+ animation.value.set(animation.finalTarget, 0);
728
+ this.#removeAnimation(animation, 'finished');
729
+ continue;
730
+ }
731
+ }
732
+
733
+ if (animation.mode === 'spring') {
734
+ next = stepDampedSpring(
735
+ animation.position,
736
+ animation.velocity,
737
+ animation.finalTarget,
738
+ animation.bounceOmega,
739
+ animation.bounceDampingRatio,
740
+ dtSeconds,
741
+ this.kineticScratch,
742
+ );
743
+ animation.position = next.position;
744
+ animation.velocity = next.velocity;
745
+
746
+ if (Math.abs(animation.finalTarget - animation.position) <= animation.restDelta
747
+ && Math.abs(animation.velocity) <= animation.restSpeed) {
748
+ animation.position = animation.finalTarget;
749
+ animation.velocity = 0;
750
+ animation.value.set(animation.finalTarget, 0);
751
+ this.#removeAnimation(animation, 'finished');
752
+ continue;
753
+ }
754
+ }
755
+
756
+ animation.value._commit(animation.position, animation.velocity);
757
+ }
758
+ }
759
+
760
+ #stepDrivers(clampedDt) {
761
+ if (this.drivers.size === 0) return;
762
+ for (const driver of this.drivers) {
763
+ if (driver.step(clampedDt) === false) this.drivers.delete(driver);
764
+ }
765
+ this.#updateThresholdStats();
766
+ if (!this.#hasWork()) this.#stopLoop();
767
+ }
768
+
769
+ #hasWork() {
770
+ return this.activeSpringCount > 0 || this.timings.length > 0 || this.kinetics.length > 0 || this.drivers.size > 0;
771
+ }
772
+
773
+ #recordPerformance(mainThreadMs, wallMs, { async = false, worker = false } = {}) {
774
+ this.stats.lastMainThreadMs = mainThreadMs;
775
+ this.stats.lastStepWallMs = wallMs;
776
+ if (async) this.stats.asyncFrames += 1;
777
+ else this.stats.syncFrames += 1;
778
+ if (worker) this.stats.workerFrames += 1;
779
+
780
+ if (this.frameBudget) {
781
+ const budget = this.frameBudget.observe(mainThreadMs);
782
+ this.stats.emaMainThreadMs = budget.emaMainThreadMs;
783
+ this.stats.budgetPressure = budget.pressure;
784
+ this.stats.budgetLevel = budget.level;
785
+ }
786
+ this.#updateThresholdStats();
787
+ }
788
+
789
+ step(dtMs) {
790
+ if (this.disposed) return;
791
+ if (this.workerFrameInFlight || this.gpuFrameInFlight) throw new Error('Cannot call step() while an asynchronous frame is in flight. Use stepAsync() or wait for the current frame.');
792
+ this.#demoteGpuToJs();
793
+ const started = nowMs();
794
+ const clampedDt = this.#prepareStep(dtMs);
795
+ if (clampedDt <= 0) return;
796
+ const driverDt = dtMs;
797
+ this.#advanceSpringBlends(clampedDt);
798
+ const springCount = this.springs.length;
799
+ if (springCount > 0) {
800
+ this.batch.step(springCount, clampedDt / 1000);
801
+ if (this.batch.kind === 'worker-wasm') this.stats.backend = `shared-wasm-${this.batch.variant}`;
802
+ this.#commitSpringValues();
803
+ }
804
+ this.#stepTimings(clampedDt);
805
+ this.#stepKinetics(clampedDt);
806
+ this.#stepDrivers(driverDt);
807
+ const elapsed = nowMs() - started;
808
+ this.#recordPerformance(elapsed, elapsed);
809
+ }
810
+
811
+ stepAsync(dtMs) {
812
+ if (this.disposed) return Promise.resolve();
813
+ const run = () => this.#stepAsyncFrame(dtMs);
814
+ const result = this.asyncStepChain.then(run, run);
815
+ this.asyncStepChain = result.catch(() => {});
816
+ return result;
817
+ }
818
+
819
+ async #stepAsyncFrame(dtMs) {
820
+ if (this.disposed) return;
821
+ const started = nowMs();
822
+ const clampedDt = this.#prepareStep(dtMs, { preferWorker: true });
823
+ if (clampedDt <= 0) return;
824
+ const driverDt = dtMs;
825
+ this.#advanceSpringBlends(clampedDt);
826
+
827
+ if (this.gpuBackend) this.maybePromoteToGpu();
828
+ if (this.workerBackend) this.maybePromoteToWorker();
829
+ const springCount = this.springs.length;
830
+ let usedWorker = false;
831
+ let dispatchCost = 0;
832
+
833
+ if (springCount > 0) {
834
+ if (this.batch.kind === 'webgpu' && this.gpuBackend === this.batch && !this.gpuUnavailable) {
835
+ this.stats.backend = 'webgpu-compute';
836
+ this.gpuFrameInFlight = true;
837
+ this.inFlightSpringCount = springCount;
838
+ const dispatchStart = nowMs();
839
+ const gpuPromise = this.batch.stepAsync(springCount, clampedDt / 1000);
840
+ dispatchCost = nowMs() - dispatchStart;
841
+
842
+ try {
843
+ await gpuPromise;
844
+ } catch (error) {
845
+ this.gpuFrameInFlight = false;
846
+ this.inFlightSpringCount = 0;
847
+ this.stats.gpuFailures += 1;
848
+ this.gpuUnavailable = true;
849
+ this.gpuBackend?.dispose();
850
+ this.gpuBackend = null;
851
+ this.#demoteGpuToJs();
852
+ this.#flushDeferredSpringMutations();
853
+ if (!this.disposed) this.#restoreBatchFromMotionValues();
854
+ this.stats.backend = this.batch?.variant ? `wasm-${this.batch.variant}` : this.batch?.kind ?? 'js';
855
+ throw error;
856
+ }
857
+
858
+ const resumed = nowMs();
859
+ this.gpuFrameInFlight = false;
860
+ this.inFlightSpringCount = 0;
861
+ this.#flushDeferredSpringMutations();
862
+ if (!this.disposed) this.#commitSpringValues();
863
+ if (!this.disposed) this.#stepTimings(clampedDt);
864
+ if (!this.disposed) this.#stepKinetics(clampedDt);
865
+ if (!this.disposed) this.#stepDrivers(driverDt);
866
+ const ended = nowMs();
867
+ const mainThreadMs = dispatchCost + (ended - resumed);
868
+ this.#recordPerformance(mainThreadMs, ended - started, { async: true, worker: false });
869
+ this.stats.gpuFrames += 1;
870
+ if (!this.#hasWork()) this.#stopLoop();
871
+ return;
872
+ }
873
+
874
+ if (this.batch.kind === 'worker-wasm' && this.workerBackend === this.batch && !this.workerUnavailable) {
875
+ usedWorker = true;
876
+ this.stats.backend = `worker-wasm-${this.batch.variant}`;
877
+ this.workerFrameInFlight = true;
878
+ this.inFlightSpringCount = springCount;
879
+ const dispatchStart = nowMs();
880
+ const workerPromise = this.batch.stepAsync(springCount, clampedDt / 1000);
881
+ dispatchCost = nowMs() - dispatchStart;
882
+
883
+ try {
884
+ await workerPromise;
885
+ } catch (error) {
886
+ this.workerFrameInFlight = false;
887
+ this.inFlightSpringCount = 0;
888
+ this.stats.workerFailures += 1;
889
+ this.workerUnavailable = true;
890
+ this.workerBackend?.dispose();
891
+ this.workerBackend = null;
892
+ this.#flushDeferredSpringMutations();
893
+ if (!this.disposed) this.#restoreBatchFromMotionValues();
894
+ this.stats.backend = this.batch?.variant ? `shared-wasm-${this.batch.variant}` : this.batch?.kind ?? 'js';
895
+ throw error;
896
+ }
897
+
898
+ const resumed = nowMs();
899
+ this.workerFrameInFlight = false;
900
+ this.inFlightSpringCount = 0;
901
+ this.#flushDeferredSpringMutations();
902
+ if (!this.disposed) this.#commitSpringValues();
903
+ if (!this.disposed) this.#stepTimings(clampedDt);
904
+ if (!this.disposed) this.#stepKinetics(clampedDt);
905
+ if (!this.disposed) this.#stepDrivers(driverDt);
906
+ const ended = nowMs();
907
+ const mainThreadMs = dispatchCost + (ended - resumed);
908
+ this.#recordPerformance(mainThreadMs, ended - started, { async: true, worker: true });
909
+ if (!this.#hasWork()) this.#stopLoop();
910
+ return;
911
+ }
912
+
913
+ this.batch.step(springCount, clampedDt / 1000);
914
+ this.#commitSpringValues();
915
+ }
916
+
917
+ this.#stepTimings(clampedDt);
918
+ this.#stepKinetics(clampedDt);
919
+ this.#stepDrivers(driverDt);
920
+ const ended = nowMs();
921
+ this.#recordPerformance(ended - started, ended - started, { async: true, worker: usedWorker });
922
+ }
923
+
924
+ #shouldAutoUseWorker() {
925
+ if (!this.autoWorkerScheduler) return false;
926
+ if (this.gpuBackend && !this.gpuUnavailable && this.activeSpringCount >= this.#effectiveGpuThreshold()) return true;
927
+ if (this.workerMode === false || this.workerUnavailable || !this.workerBackend) return false;
928
+ return this.activeSpringCount >= this.#effectiveWorkerThreshold();
929
+ }
930
+
931
+ #ensureRunning() {
932
+ if (!this.autoStart || this.running || this.disposed) return;
933
+ this.running = true;
934
+ // Seed the clock when work is scheduled instead of burning the first RAF
935
+ // solely to establish a timestamp. Real RAF timestamps and performance.now()
936
+ // share the same time origin, so the first visible frame can advance motion.
937
+ this.lastTime = nowMs();
938
+
939
+ const tick = (time) => {
940
+ if (!this.running || this.disposed) return;
941
+ const dt = time - this.lastTime;
942
+ this.lastTime = time;
943
+ // Synthetic schedulers/tests may use a different timestamp origin. Rebase
944
+ // once rather than feeding a negative/invalid delta into the integrators.
945
+ if (!Number.isFinite(dt) || dt <= 0) {
946
+ if (this.running) this.frameId = defaultRaf(tick);
947
+ return;
948
+ }
949
+
950
+ if (this.#shouldAutoUseWorker() || this.workerFrameInFlight || this.gpuFrameInFlight) {
951
+ this.stepAsync(dt)
952
+ .catch(() => {})
953
+ .finally(() => {
954
+ if (this.running && !this.disposed) this.frameId = defaultRaf(tick);
955
+ });
956
+ return;
957
+ }
958
+
959
+ try {
960
+ this.step(dt);
961
+ } catch {
962
+ // A manual async step may have acquired the Worker between the check
963
+ // above and this synchronous step. Serialize behind it instead.
964
+ this.stepAsync(dt).catch(() => {});
965
+ }
966
+ if (this.running && !this.disposed) this.frameId = defaultRaf(tick);
967
+ };
968
+
969
+ this.frameId = defaultRaf(tick);
970
+ }
971
+
972
+ #stopLoop() {
973
+ if (!this.running) return;
974
+ this.running = false;
975
+ this.lastTime = null;
976
+ if (this.frameId != null) defaultCancelRaf(this.frameId);
977
+ this.frameId = null;
978
+ }
979
+
980
+ getBackendPlan() {
981
+ this.#updateThresholdStats();
982
+ return {
983
+ current: this.stats.backend,
984
+ activeSprings: this.activeSpringCount,
985
+ activeKinetics: this.kinetics.length,
986
+ wasm: {
987
+ mode: this.wasmMode,
988
+ ready: Boolean(this.wasmBatch),
989
+ threshold: this.stats.effectiveWasmThreshold,
990
+ },
991
+ worker: {
992
+ mode: this.workerMode,
993
+ ready: Boolean(this.workerBackend),
994
+ unavailable: this.workerUnavailable,
995
+ threshold: this.stats.effectiveWorkerThreshold,
996
+ inFlight: this.workerFrameInFlight,
997
+ },
998
+ gpu: {
999
+ mode: this.gpuMode,
1000
+ ready: Boolean(this.gpuBackend),
1001
+ unavailable: this.gpuUnavailable,
1002
+ threshold: this.stats.effectiveGpuThreshold,
1003
+ inFlight: this.gpuFrameInFlight,
1004
+ },
1005
+ budget: this.frameBudget?.snapshot() ?? null,
1006
+ };
1007
+ }
1008
+
1009
+ dispose() {
1010
+ if (this.disposed) return;
1011
+ this.disposed = true;
1012
+ this.#stopLoop();
1013
+ for (const animation of [...this.springs, ...this.timings, ...this.kinetics]) {
1014
+ if (animation.active !== false) animation.controlState.settle({ status: 'cancelled', value: animation.value.get() });
1015
+ }
1016
+ this.springs.length = 0;
1017
+ this.activeSpringCount = 0;
1018
+ this.timings.length = 0;
1019
+ this.kinetics.length = 0;
1020
+ this.byValue.clear();
1021
+ for (const driver of this.drivers) driver.onEngineDispose?.();
1022
+ this.drivers.clear();
1023
+ this.pendingSpringSync.clear();
1024
+ this.deferredSpringRemovals.clear();
1025
+ this.workerBackend?.dispose();
1026
+ this.workerBackend = null;
1027
+ this.gpuBackend?.dispose();
1028
+ this.gpuBackend = null;
1029
+ this.#updateThresholdStats();
1030
+ }
1031
+ }