@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,94 @@
1
+ import { FrameBatcher } from '../render/frame-batcher.js';
2
+
3
+ function normalizeValues(input) {
4
+ return Array.isArray(input) ? input : [input];
5
+ }
6
+
7
+ function inferType(length) {
8
+ if (length === 1) return '1f';
9
+ if (length >= 2 && length <= 4) return `${length}fv`;
10
+ if (length === 16) return 'matrix4fv';
11
+ throw new RangeError('WebGL uniform bindings support 1, 2, 3, 4, or 16 scalar values.');
12
+ }
13
+
14
+ export class WebGLUniformBinder {
15
+ constructor(gl, program, bindings = [], {
16
+ autoUseProgram = true,
17
+ requestFrame,
18
+ cancelFrame,
19
+ flushInitial = true,
20
+ } = {}) {
21
+ if (!gl) throw new TypeError('WebGLUniformBinder requires a WebGL-like context.');
22
+ this.gl = gl;
23
+ this.program = program;
24
+ this.autoUseProgram = autoUseProgram;
25
+ this.entries = [];
26
+ this.unsubscribers = [];
27
+ this.dirtyCount = 0;
28
+ this.disposed = false;
29
+ this.batcher = new FrameBatcher(() => this.flush(), { requestFrame, cancelFrame });
30
+ for (const binding of bindings) this.add(binding);
31
+ if (flushInitial && this.entries.length) this.batcher.invalidate();
32
+ }
33
+
34
+ add({ name, location, values, value, type } = {}) {
35
+ const motions = normalizeValues(values ?? value);
36
+ if (motions.length === 0 || motions.some((motion) => !motion?.get)) throw new TypeError('WebGL uniform binding requires MotionValue-like values.');
37
+ const resolvedLocation = location ?? this.gl.getUniformLocation?.(this.program, name);
38
+ if (resolvedLocation == null) throw new Error(`WebGL uniform '${name ?? '<unnamed>'}' was not found.`);
39
+ const resolvedType = type ?? inferType(motions.length);
40
+ const data = new Float32Array(motions.length);
41
+ const entry = { name, location: resolvedLocation, values: motions, type: resolvedType, data, dirty: true };
42
+ this.entries.push(entry);
43
+ this.dirtyCount += 1;
44
+
45
+ motions.forEach((motion, index) => {
46
+ data[index] = Number(motion.get()) || 0;
47
+ const subscribe = motion.subscribeValue ?? motion.subscribe;
48
+ this.unsubscribers.push(subscribe.call(motion, (next) => {
49
+ data[index] = Number(next) || 0;
50
+ if (!entry.dirty) { entry.dirty = true; this.dirtyCount += 1; }
51
+ this.batcher.invalidate();
52
+ }, { emitCurrent: false }));
53
+ });
54
+ return this;
55
+ }
56
+
57
+ flush() {
58
+ if (this.disposed || this.dirtyCount === 0) return 0;
59
+ const gl = this.gl;
60
+ if (this.autoUseProgram && typeof gl.useProgram === 'function') gl.useProgram(this.program);
61
+ let writes = 0;
62
+ for (const entry of this.entries) {
63
+ if (!entry.dirty) continue;
64
+ switch (entry.type) {
65
+ case '1f': gl.uniform1f(entry.location, entry.data[0]); break;
66
+ case '2fv': gl.uniform2fv(entry.location, entry.data); break;
67
+ case '3fv': gl.uniform3fv(entry.location, entry.data); break;
68
+ case '4fv': gl.uniform4fv(entry.location, entry.data); break;
69
+ case 'matrix4fv': gl.uniformMatrix4fv(entry.location, false, entry.data); break;
70
+ default: throw new TypeError(`Unsupported WebGL uniform type '${entry.type}'.`);
71
+ }
72
+ entry.dirty = false;
73
+ writes += 1;
74
+ }
75
+ this.dirtyCount = 0;
76
+ return writes;
77
+ }
78
+
79
+ flushNow() { this.batcher.flushNow(); return this; }
80
+
81
+ dispose() {
82
+ if (this.disposed) return;
83
+ this.disposed = true;
84
+ this.batcher.dispose();
85
+ for (const unsubscribe of this.unsubscribers) unsubscribe?.();
86
+ this.unsubscribers.length = 0;
87
+ this.entries.length = 0;
88
+ this.dirtyCount = 0;
89
+ }
90
+ }
91
+
92
+ export function createWebGLUniformBinder(gl, program, bindings, options) {
93
+ return new WebGLUniformBinder(gl, program, bindings, options);
94
+ }
@@ -0,0 +1,35 @@
1
+ import type { MotionValue } from '../../index.js';
2
+ export class WebGPUSpringBatch {
3
+ constructor(device: unknown, capacity?: number);
4
+ static isSupported(device?: unknown): boolean;
5
+ static create(capacity?: number, device?: unknown): Promise<WebGPUSpringBatch>;
6
+ readonly kind: 'webgpu';
7
+ readonly variant: 'compute';
8
+ readonly capacity: number;
9
+ readonly positions: Float32Array;
10
+ readonly velocities: Float32Array;
11
+ readonly targets: Float32Array;
12
+ readonly omegas: Float32Array;
13
+ readonly dampingRatios: Float32Array;
14
+ ensureCapacity(required: number): void;
15
+ step(count: number, dtSeconds: number): never;
16
+ stepAsync(count: number, dtSeconds: number): Promise<void>;
17
+ copyInto(other: { positions: Float32Array; velocities: Float32Array; targets: Float32Array; omegas: Float32Array; dampingRatios: Float32Array }, count: number): void;
18
+ dispose(): void;
19
+ }
20
+ export type WebGPUValueBinding = MotionValue | { value: MotionValue; index?: number };
21
+ export class WebGPUBufferBinder {
22
+ constructor(device: { queue: { writeBuffer(...args: any[]): void } }, buffer: unknown, bindings: WebGPUValueBinding[], options?: {
23
+ byteOffset?: number;
24
+ floatCount?: number;
25
+ requestFrame?: (callback: FrameRequestCallback) => any;
26
+ cancelFrame?: (id: any) => void;
27
+ flushInitial?: boolean;
28
+ });
29
+ readonly data: Float32Array;
30
+ readonly writes: number;
31
+ flush(): boolean;
32
+ flushNow(): this;
33
+ dispose(): void;
34
+ }
35
+ export function createWebGPUBufferBinder(device: ConstructorParameters<typeof WebGPUBufferBinder>[0], buffer: unknown, bindings: WebGPUValueBinding[], options?: ConstructorParameters<typeof WebGPUBufferBinder>[3]): WebGPUBufferBinder;
@@ -0,0 +1,73 @@
1
+ import { FrameBatcher } from '../render/frame-batcher.js';
2
+ export { WebGPUSpringBatch } from './spring-batch.js';
3
+
4
+ function normalizeBinding(binding, fallbackIndex) {
5
+ if (binding?.get) return { value: binding, index: fallbackIndex };
6
+ if (binding?.value?.get) return { value: binding.value, index: Number.isInteger(binding.index) ? binding.index : fallbackIndex };
7
+ throw new TypeError('WebGPU binding requires a MotionValue or { value, index } entry.');
8
+ }
9
+
10
+ export class WebGPUBufferBinder {
11
+ constructor(device, buffer, bindings, {
12
+ byteOffset = 0,
13
+ floatCount,
14
+ requestFrame,
15
+ cancelFrame,
16
+ flushInitial = true,
17
+ } = {}) {
18
+ if (!device?.queue?.writeBuffer) throw new TypeError('WebGPUBufferBinder requires a GPUDevice-like object with queue.writeBuffer().');
19
+ if (!buffer) throw new TypeError('WebGPUBufferBinder requires a GPUBuffer-like target.');
20
+ if (!Array.isArray(bindings) || bindings.length === 0) throw new TypeError('WebGPUBufferBinder requires at least one binding.');
21
+ this.device = device;
22
+ this.buffer = buffer;
23
+ this.byteOffset = Math.max(0, Math.floor(Number(byteOffset) || 0));
24
+ this.bindings = bindings.map(normalizeBinding);
25
+ const required = this.bindings.reduce((max, entry) => Math.max(max, entry.index + 1), 0);
26
+ const count = floatCount == null ? required : Math.max(required, Math.floor(floatCount));
27
+ this.data = new Float32Array(count);
28
+ this.unsubscribers = [];
29
+ this.writes = 0;
30
+ this.disposed = false;
31
+ this.dirty = true;
32
+ this.batcher = new FrameBatcher(() => this.flush(), { requestFrame, cancelFrame });
33
+
34
+ for (const entry of this.bindings) {
35
+ this.data[entry.index] = Number(entry.value.get()) || 0;
36
+ const subscribe = entry.value.subscribeValue ?? entry.value.subscribe;
37
+ this.unsubscribers.push(subscribe.call(entry.value, (next) => {
38
+ this.data[entry.index] = Number(next) || 0;
39
+ this.dirty = true;
40
+ this.batcher.invalidate();
41
+ }, { emitCurrent: false }));
42
+ }
43
+ if (flushInitial) this.batcher.invalidate();
44
+ }
45
+
46
+ flush() {
47
+ if (this.disposed || !this.dirty) return false;
48
+ this.device.queue.writeBuffer(
49
+ this.buffer,
50
+ this.byteOffset,
51
+ this.data.buffer,
52
+ this.data.byteOffset,
53
+ this.data.byteLength,
54
+ );
55
+ this.dirty = false;
56
+ this.writes += 1;
57
+ return true;
58
+ }
59
+
60
+ flushNow() { this.batcher.flushNow(); return this; }
61
+
62
+ dispose() {
63
+ if (this.disposed) return;
64
+ this.disposed = true;
65
+ this.batcher.dispose();
66
+ for (const unsubscribe of this.unsubscribers) unsubscribe?.();
67
+ this.unsubscribers.length = 0;
68
+ }
69
+ }
70
+
71
+ export function createWebGPUBufferBinder(device, buffer, bindings, options) {
72
+ return new WebGPUBufferBinder(device, buffer, bindings, options);
73
+ }
@@ -0,0 +1,218 @@
1
+ const DEFAULT_WORKGROUP_SIZE = 64;
2
+ const MAX_STEP_SECONDS = 1 / 240;
3
+ const MAX_SUBSTEPS = 32;
4
+ const FLOATS_PER_SPRING = 8;
5
+
6
+ const BUFFER_USAGE = globalThis.GPUBufferUsage ?? {
7
+ MAP_READ: 1,
8
+ COPY_SRC: 4,
9
+ COPY_DST: 8,
10
+ STORAGE: 128,
11
+ UNIFORM: 64,
12
+ };
13
+ const MAP_MODE = globalThis.GPUMapMode ?? { READ: 1 };
14
+
15
+ const SPRING_SHADER = /* wgsl */ `
16
+ struct Spring {
17
+ state: vec4<f32>,
18
+ dynamics: vec4<f32>,
19
+ };
20
+
21
+ struct Params {
22
+ dt: f32,
23
+ count: u32,
24
+ _padding: vec2<u32>,
25
+ };
26
+
27
+ @group(0) @binding(0) var<storage, read_write> springs: array<Spring>;
28
+ @group(0) @binding(1) var<uniform> params: Params;
29
+
30
+ @compute @workgroup_size(64)
31
+ fn step(@builtin(global_invocation_id) id: vec3<u32>) {
32
+ if (id.x >= params.count) { return; }
33
+
34
+ var spring = springs[id.x];
35
+ let x = spring.state.x;
36
+ let v = spring.state.y;
37
+ let target = spring.state.z;
38
+ let omega = spring.state.w;
39
+ let damping = spring.dynamics.x;
40
+ let acceleration = omega * omega * (target - x) - 2.0 * damping * omega * v;
41
+ let nextVelocity = v + acceleration * params.dt;
42
+ spring.state.x = x + nextVelocity * params.dt;
43
+ spring.state.y = nextVelocity;
44
+ springs[id.x] = spring;
45
+ }
46
+ `;
47
+
48
+ function normalizeCapacity(capacity) {
49
+ const value = Math.floor(Number(capacity));
50
+ if (!Number.isFinite(value) || value < 1) throw new RangeError('WebGPU spring capacity must be a positive integer.');
51
+ return value;
52
+ }
53
+
54
+ function substepCount(dtSeconds) {
55
+ let steps = 1;
56
+ while (dtSeconds / steps > MAX_STEP_SECONDS && steps < MAX_SUBSTEPS) steps += 1;
57
+ return steps;
58
+ }
59
+
60
+ /**
61
+ * WebGPU compute backend for dense spring batches. The host arrays mirror the
62
+ * JS/WASM batch contract so MotionEngine can promote and demote without
63
+ * changing animation semantics. Frames are asynchronous because readback is
64
+ * required before values can be committed to MotionValue instances.
65
+ */
66
+ export class WebGPUSpringBatch {
67
+ static isSupported(device) {
68
+ return Boolean(device?.createBuffer
69
+ && device?.createShaderModule
70
+ && device?.createComputePipeline
71
+ && device?.createBindGroup
72
+ && device?.createCommandEncoder
73
+ && device?.queue?.submit
74
+ && device?.queue?.writeBuffer);
75
+ }
76
+
77
+ static async create(capacity = 65536, device) {
78
+ if (capacity && typeof capacity === 'object') {
79
+ device = capacity;
80
+ capacity = 65536;
81
+ }
82
+ let resolvedDevice = device;
83
+ if (!resolvedDevice) {
84
+ const gpu = globalThis.navigator?.gpu ?? globalThis.gpu;
85
+ if (!gpu?.requestAdapter) throw new Error('WebGPU is unavailable in this environment.');
86
+ const adapter = await gpu.requestAdapter();
87
+ if (!adapter?.requestDevice) throw new Error('No WebGPU adapter is available.');
88
+ resolvedDevice = await adapter.requestDevice();
89
+ }
90
+ if (!WebGPUSpringBatch.isSupported(resolvedDevice)) throw new Error('The WebGPU device does not support compute buffers.');
91
+ return new WebGPUSpringBatch(resolvedDevice, capacity);
92
+ }
93
+
94
+ constructor(device, capacity = 65536) {
95
+ if (!WebGPUSpringBatch.isSupported(device)) throw new TypeError('WebGPUSpringBatch requires a GPUDevice-like object.');
96
+ this.kind = 'webgpu';
97
+ this.variant = 'compute';
98
+ this.device = device;
99
+ this.capacity = normalizeCapacity(capacity);
100
+ this.positions = new Float32Array(this.capacity);
101
+ this.velocities = new Float32Array(this.capacity);
102
+ this.targets = new Float32Array(this.capacity);
103
+ this.omegas = new Float32Array(this.capacity);
104
+ this.dampingRatios = new Float32Array(this.capacity);
105
+ this.packed = new Float32Array(this.capacity * FLOATS_PER_SPRING);
106
+ this.params = new Uint32Array(4);
107
+ this.paramsFloat = new Float32Array(this.params.buffer);
108
+ this.stepChain = Promise.resolve();
109
+ this.disposed = false;
110
+
111
+ const byteLength = this.packed.byteLength;
112
+ this.storageBuffer = device.createBuffer({
113
+ size: byteLength,
114
+ usage: BUFFER_USAGE.STORAGE | BUFFER_USAGE.COPY_SRC | BUFFER_USAGE.COPY_DST,
115
+ });
116
+ this.readbackBuffer = device.createBuffer({
117
+ size: byteLength,
118
+ usage: BUFFER_USAGE.MAP_READ | BUFFER_USAGE.COPY_DST,
119
+ });
120
+ this.paramsBuffer = device.createBuffer({
121
+ size: this.params.byteLength,
122
+ usage: BUFFER_USAGE.UNIFORM | BUFFER_USAGE.COPY_DST,
123
+ });
124
+ const module = device.createShaderModule({ code: SPRING_SHADER });
125
+ this.pipeline = device.createComputePipeline({
126
+ layout: 'auto',
127
+ compute: { module, entryPoint: 'step' },
128
+ });
129
+ this.bindGroup = device.createBindGroup({
130
+ layout: this.pipeline.getBindGroupLayout(0),
131
+ entries: [
132
+ { binding: 0, resource: { buffer: this.storageBuffer } },
133
+ { binding: 1, resource: { buffer: this.paramsBuffer } },
134
+ ],
135
+ });
136
+ }
137
+
138
+ ensureCapacity(required) {
139
+ if (required > this.capacity) throw new RangeError(`WebGPU spring capacity exceeded (${required} > ${this.capacity}).`);
140
+ }
141
+
142
+ copyInto(other, count) {
143
+ other.positions.set(this.positions.subarray(0, count));
144
+ other.velocities.set(this.velocities.subarray(0, count));
145
+ other.targets.set(this.targets.subarray(0, count));
146
+ other.omegas.set(this.omegas.subarray(0, count));
147
+ other.dampingRatios.set(this.dampingRatios.subarray(0, count));
148
+ }
149
+
150
+ step() {
151
+ throw new Error('WebGPUSpringBatch.step() is asynchronous; use stepAsync().');
152
+ }
153
+
154
+ #pack(count) {
155
+ for (let i = 0; i < count; i += 1) {
156
+ const offset = i * FLOATS_PER_SPRING;
157
+ this.packed[offset] = this.positions[i];
158
+ this.packed[offset + 1] = this.velocities[i];
159
+ this.packed[offset + 2] = this.targets[i];
160
+ this.packed[offset + 3] = this.omegas[i];
161
+ this.packed[offset + 4] = this.dampingRatios[i];
162
+ this.packed[offset + 5] = 0;
163
+ this.packed[offset + 6] = 0;
164
+ this.packed[offset + 7] = 0;
165
+ }
166
+ }
167
+
168
+ #unpack(count, mapped) {
169
+ this.packed.set(new Float32Array(mapped).subarray(0, count * FLOATS_PER_SPRING));
170
+ for (let i = 0; i < count; i += 1) {
171
+ const offset = i * FLOATS_PER_SPRING;
172
+ this.positions[i] = this.packed[offset];
173
+ this.velocities[i] = this.packed[offset + 1];
174
+ }
175
+ }
176
+
177
+ async #step(count, dtSeconds) {
178
+ if (this.disposed) return;
179
+ if (count <= 0 || dtSeconds <= 0) return;
180
+ this.ensureCapacity(count);
181
+ this.#pack(count);
182
+ this.paramsFloat[0] = dtSeconds / substepCount(dtSeconds);
183
+ this.params[1] = count;
184
+ this.device.queue.writeBuffer(this.storageBuffer, 0, this.packed, 0, count * FLOATS_PER_SPRING * Float32Array.BYTES_PER_ELEMENT);
185
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.params);
186
+
187
+ const encoder = this.device.createCommandEncoder();
188
+ const pass = encoder.beginComputePass();
189
+ pass.setPipeline(this.pipeline);
190
+ pass.setBindGroup(0, this.bindGroup);
191
+ const steps = substepCount(dtSeconds);
192
+ for (let i = 0; i < steps; i += 1) pass.dispatchWorkgroups(Math.ceil(count / DEFAULT_WORKGROUP_SIZE));
193
+ pass.end();
194
+ const readbackBytes = count * FLOATS_PER_SPRING * Float32Array.BYTES_PER_ELEMENT;
195
+ encoder.copyBufferToBuffer(this.storageBuffer, 0, this.readbackBuffer, 0, readbackBytes);
196
+ this.device.queue.submit([encoder.finish()]);
197
+ await this.device.queue.onSubmittedWorkDone?.();
198
+ await this.readbackBuffer.mapAsync(MAP_MODE.READ);
199
+ const mapped = this.readbackBuffer.getMappedRange();
200
+ this.#unpack(count, mapped);
201
+ this.readbackBuffer.unmap();
202
+ }
203
+
204
+ stepAsync(count, dtSeconds) {
205
+ const run = () => this.#step(count, dtSeconds);
206
+ const result = this.stepChain.then(run, run);
207
+ this.stepChain = result.catch(() => {});
208
+ return result;
209
+ }
210
+
211
+ dispose() {
212
+ if (this.disposed) return;
213
+ this.disposed = true;
214
+ this.storageBuffer.destroy?.();
215
+ this.readbackBuffer.destroy?.();
216
+ this.paramsBuffer.destroy?.();
217
+ }
218
+ }
@@ -0,0 +1,17 @@
1
+ export class SharedSpringWorkerBackend {
2
+ static isSupported(): boolean;
3
+ static create(capacity?: number): Promise<SharedSpringWorkerBackend>;
4
+ readonly kind: 'worker-wasm';
5
+ readonly capacity: number;
6
+ readonly variant: 'simd' | 'scalar';
7
+ readonly atomicCompletion: boolean;
8
+ readonly positions: Float32Array;
9
+ readonly velocities: Float32Array;
10
+ readonly targets: Float32Array;
11
+ readonly omegas: Float32Array;
12
+ readonly dampingRatios: Float32Array;
13
+ ensureCapacity(required: number): void;
14
+ step(count: number, dtSeconds: number): void;
15
+ stepAsync(count: number, dtSeconds: number): Promise<void>;
16
+ dispose(): void;
17
+ }
@@ -0,0 +1 @@
1
+ export { SharedSpringWorkerBackend } from './shared-spring-worker.js';
@@ -0,0 +1,218 @@
1
+ import { SharedWasmSpringBatch } from '../wasm/shared-wasm-spring-batch.js';
2
+
3
+ function canUseSharedMemory() {
4
+ try {
5
+ if (typeof SharedArrayBuffer !== 'function' || typeof WebAssembly?.Memory !== 'function') return false;
6
+ const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });
7
+ return memory.buffer instanceof SharedArrayBuffer;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ async function createWorker(url) {
14
+ if (typeof globalThis.Worker === 'function') return new globalThis.Worker(url, { type: 'module' });
15
+ if (typeof process !== 'undefined' && process.versions?.node) {
16
+ const nodeWorkers = ['node', 'worker_threads'].join(':');
17
+ const { Worker } = await import(/* @vite-ignore */ nodeWorkers);
18
+ const execArgv = process.execArgv.filter((arg) => !arg.startsWith('--input-type'));
19
+ try {
20
+ return new Worker(url, { type: 'module', execArgv });
21
+ } catch (error) {
22
+ // Desktop shells and test runners can inject V8 flags that are valid in
23
+ // the parent process but rejected by worker_threads. Retrying without
24
+ // inherited flags keeps the shared backend available instead of
25
+ // silently forcing the slower main-thread solver.
26
+ if (error?.code !== 'ERR_WORKER_INVALID_EXEC_ARGV') throw error;
27
+ return new Worker(url, { type: 'module', execArgv: [] });
28
+ }
29
+ }
30
+ throw new Error('Module Worker is unavailable.');
31
+ }
32
+
33
+ function onWorkerMessage(worker, handler) {
34
+ if (typeof worker.addEventListener === 'function') {
35
+ const listener = (event) => handler(event.data);
36
+ worker.addEventListener('message', listener);
37
+ return () => worker.removeEventListener('message', listener);
38
+ }
39
+ worker.on('message', handler);
40
+ return () => worker.off?.('message', handler);
41
+ }
42
+
43
+ function onWorkerError(worker, handler) {
44
+ if (typeof worker.addEventListener === 'function') {
45
+ const listener = (event) => handler(event.error ?? new Error(event.message || 'Worker failed.'));
46
+ worker.addEventListener('error', listener);
47
+ return () => worker.removeEventListener('error', listener);
48
+ }
49
+ worker.on?.('error', handler);
50
+ return () => worker.off?.('error', handler);
51
+ }
52
+
53
+ export class SharedSpringWorkerBackend {
54
+ static isSupported() { return canUseSharedMemory(); }
55
+
56
+ static async create(capacity = 65536) {
57
+ if (!canUseSharedMemory()) throw new Error('Shared WebAssembly memory is unavailable in this environment.');
58
+ const batch = await SharedWasmSpringBatch.create(capacity);
59
+ const worker = await createWorker(new URL('./shared-worker.js', import.meta.url));
60
+ const controlBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 8);
61
+ const backend = new SharedSpringWorkerBackend(batch, worker, controlBuffer);
62
+ try {
63
+ await backend.#initialize();
64
+ return backend;
65
+ } catch (error) {
66
+ backend.dispose();
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ constructor(batch, worker, controlBuffer) {
72
+ this.kind = 'worker-wasm';
73
+ this.batch = batch;
74
+ this.worker = worker;
75
+ this.controlBuffer = controlBuffer;
76
+ this.control = new Int32Array(controlBuffer);
77
+ this.controlFloat = new Float32Array(controlBuffer);
78
+ this.pending = new Map();
79
+ this.sequence = 0;
80
+ this.atomicCompletion = typeof Atomics.waitAsync === 'function';
81
+ this.workerFailure = null;
82
+ this.stepChain = Promise.resolve();
83
+ this.ready = false;
84
+ this.disposed = false;
85
+ this.removeListener = onWorkerMessage(worker, (message) => this.#onMessage(message));
86
+ this.removeErrorListener = onWorkerError(worker, (error) => this.#onWorkerError(error));
87
+ }
88
+
89
+ get capacity() { return this.batch.capacity; }
90
+ get variant() { return this.batch.variant; }
91
+ get positions() { return this.batch.positions; }
92
+ get velocities() { return this.batch.velocities; }
93
+ get targets() { return this.batch.targets; }
94
+ get omegas() { return this.batch.omegas; }
95
+ get dampingRatios() { return this.batch.dampingRatios; }
96
+
97
+ ensureCapacity(required) { this.batch.ensureCapacity(required); }
98
+ copyInto(other, count) { this.batch.copyInto(other, count); }
99
+
100
+ async #initialize() {
101
+ const ready = new Promise((resolve, reject) => {
102
+ this.readyResolver = resolve;
103
+ this.readyRejecter = reject;
104
+ });
105
+ this.worker.postMessage({
106
+ type: 'init',
107
+ memory: this.batch.memory,
108
+ variant: this.batch.variant,
109
+ ptrs: this.batch.ptrs,
110
+ controlBuffer: this.controlBuffer,
111
+ atomicCompletion: this.atomicCompletion,
112
+ });
113
+ await ready;
114
+ }
115
+
116
+ #onWorkerError(error) {
117
+ this.workerFailure = error;
118
+ Atomics.store(this.control, 5, 3);
119
+ Atomics.store(this.control, 1, this.sequence);
120
+ Atomics.notify(this.control, 1);
121
+ if (!this.ready) this.readyRejecter?.(error);
122
+ for (const { reject } of this.pending.values()) reject(error);
123
+ this.pending.clear();
124
+ }
125
+
126
+ #onMessage(message) {
127
+ if (message?.type === 'ready') {
128
+ this.ready = true;
129
+ this.readyResolver?.(message);
130
+ this.readyResolver = null;
131
+ this.readyRejecter = null;
132
+ return;
133
+ }
134
+ if (message?.type === 'error') {
135
+ const error = new Error(message.message || 'Shared spring worker failed.');
136
+ this.workerFailure = error;
137
+ Atomics.store(this.control, 5, 3);
138
+ Atomics.store(this.control, 1, this.sequence);
139
+ Atomics.notify(this.control, 1);
140
+ if (!this.ready) this.readyRejecter?.(error);
141
+ for (const { reject } of this.pending.values()) reject(error);
142
+ this.pending.clear();
143
+ return;
144
+ }
145
+ if (message?.type === 'done') {
146
+ const pending = this.pending.get(message.sequence);
147
+ if (pending) {
148
+ this.pending.delete(message.sequence);
149
+ pending.resolve();
150
+ }
151
+ }
152
+ }
153
+
154
+ step(count, dtSeconds) {
155
+ // Synchronous/manual stepping keeps deterministic semantics and uses the
156
+ // shared WASM instance on the caller thread. Auto mode can use stepAsync().
157
+ this.batch.step(count, dtSeconds);
158
+ }
159
+
160
+ stepAsync(count, dtSeconds) {
161
+ if (this.disposed) return Promise.reject(new Error('Shared spring worker is disposed.'));
162
+ if (count === 0 || dtSeconds <= 0) return Promise.resolve();
163
+ const dispatch = () => this.#dispatchStep(count, dtSeconds);
164
+ const result = this.stepChain.then(dispatch, dispatch);
165
+ this.stepChain = result.catch(() => {});
166
+ return result;
167
+ }
168
+
169
+ #dispatchStep(count, dtSeconds) {
170
+ if (this.disposed) return Promise.reject(new Error('Shared spring worker is disposed.'));
171
+ const sequence = ++this.sequence;
172
+ Atomics.store(this.control, 2, count);
173
+ Atomics.store(this.control, 5, 0);
174
+ this.controlFloat[4] = dtSeconds;
175
+
176
+ let promise;
177
+ if (this.atomicCompletion) {
178
+ promise = this.#waitForSequence(sequence);
179
+ } else {
180
+ promise = new Promise((resolve, reject) => this.pending.set(sequence, { resolve, reject }));
181
+ }
182
+
183
+ Atomics.store(this.control, 0, sequence);
184
+ Atomics.notify(this.control, 0);
185
+ return promise;
186
+ }
187
+
188
+ async #waitForSequence(sequence) {
189
+ while (true) {
190
+ if (this.disposed) throw new Error('Shared spring worker is disposed.');
191
+ if (this.workerFailure) throw this.workerFailure;
192
+ const completed = Atomics.load(this.control, 1);
193
+ if (completed >= sequence) {
194
+ const errorCode = Atomics.load(this.control, 5);
195
+ if (errorCode !== 0) throw new Error(`Shared spring worker failed with code ${errorCode}.`);
196
+ return;
197
+ }
198
+ const waiter = Atomics.waitAsync(this.control, 1, completed);
199
+ if (waiter.async) await waiter.value;
200
+ else await Promise.resolve();
201
+ }
202
+ }
203
+
204
+ dispose() {
205
+ if (this.disposed) return;
206
+ this.disposed = true;
207
+ Atomics.store(this.control, 3, 1);
208
+ Atomics.store(this.control, 5, 2);
209
+ Atomics.store(this.control, 1, this.sequence);
210
+ Atomics.notify(this.control, 0);
211
+ Atomics.notify(this.control, 1);
212
+ for (const { reject } of this.pending.values()) reject(new Error('Shared spring worker disposed.'));
213
+ this.pending.clear();
214
+ this.removeListener?.();
215
+ this.removeErrorListener?.();
216
+ this.worker.terminate?.();
217
+ }
218
+ }