fancy-ui-svelte 0.7.0 → 0.9.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.
Files changed (58) hide show
  1. package/README.md +107 -81
  2. package/dist/fancy-ui/animated-testimonials/README.md +44 -0
  3. package/dist/fancy-ui/animated-tooltip/README.md +40 -0
  4. package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
  5. package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
  6. package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
  7. package/dist/fancy-ui/border-beam/README.md +37 -0
  8. package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
  9. package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
  10. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
  11. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
  12. package/dist/fancy-ui/displacement-text/README.md +39 -0
  13. package/dist/fancy-ui/dock/README.md +1 -1
  14. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
  15. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
  16. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +1250 -1108
  17. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +11 -5
  18. package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +48 -0
  19. package/dist/fancy-ui/fluid-cursor/README.md +57 -6
  20. package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +72 -0
  21. package/dist/fancy-ui/fluid-cursor/fluid-shared.js +125 -0
  22. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.d.ts +1 -0
  23. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +39 -0
  24. package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
  25. package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +36 -0
  26. package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +926 -0
  27. package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte +5 -3
  28. package/dist/fancy-ui/frosted-glass/README.md +4 -2
  29. package/dist/fancy-ui/index.d.ts +1 -0
  30. package/dist/fancy-ui/index.js +1 -0
  31. package/dist/fancy-ui/line-hover-link/README.md +37 -0
  32. package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
  33. package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
  34. package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte +19 -2
  35. package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte.d.ts +4 -0
  36. package/dist/fancy-ui/liquid-glass/LiquidGlass.test.js +14 -0
  37. package/dist/fancy-ui/liquid-glass/README.md +7 -1
  38. package/dist/fancy-ui/liquid-text/LiquidText.svelte +1151 -0
  39. package/dist/fancy-ui/liquid-text/LiquidText.svelte.d.ts +37 -0
  40. package/dist/fancy-ui/liquid-text/LiquidText.test.d.ts +1 -0
  41. package/dist/fancy-ui/liquid-text/LiquidText.test.js +219 -0
  42. package/dist/fancy-ui/liquid-text/README.md +139 -0
  43. package/dist/fancy-ui/liquid-text/index.d.ts +2 -0
  44. package/dist/fancy-ui/liquid-text/index.js +1 -0
  45. package/dist/fancy-ui/marquee/README.md +57 -0
  46. package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
  47. package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
  48. package/dist/fancy-ui/matrix-rain/README.md +42 -0
  49. package/dist/fancy-ui/noise-reveal/README.md +52 -0
  50. package/dist/fancy-ui/rainbow-button/README.md +36 -0
  51. package/dist/fancy-ui/registry.js +144 -20
  52. package/dist/fancy-ui/ripple-button/README.md +36 -0
  53. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
  54. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
  55. package/dist/fancy-ui/terminal-text/README.md +48 -0
  56. package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
  57. package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
  58. package/package.json +18 -4
@@ -0,0 +1,926 @@
1
+ /// <reference types="@webgpu/types" />
2
+ // WebGPU HDR engine for FluidCursor.
3
+ //
4
+ // Port of the WebGL fluid simulation to WGSL, rendering into an
5
+ // `rgba16float` canvas configured with `colorSpace: "display-p3"` and
6
+ // `toneMapping: { mode: "extended" }`. On HDR displays, dye values pushed
7
+ // above 1.0 by the exposure boost render brighter than SDR white; on wide
8
+ // gamut (P3) displays colors are noticeably more saturated. Browsers that
9
+ // ignore the `toneMapping` member simply clamp to SDR — the sim still runs.
10
+ //
11
+ // The math mirrors the GLSL shaders in FluidCursor.svelte pass for pass
12
+ // (splat, curl, vorticity, divergence, clear, pressure Jacobi iterations,
13
+ // gradient subtract, advection, display). Any behavioral change here must be
14
+ // mirrored in the WebGL path and vice versa.
15
+ import { pointerPrototype, correctDeltaX, correctDeltaY, correctRadius, getSimResolution, scaleRadiusForContainer, } from "./fluid-shared.js";
16
+ const WGSL = /* wgsl */ `
17
+ struct Uniforms {
18
+ texelSize: vec2f,
19
+ point: vec2f,
20
+ color: vec4f,
21
+ dt: f32,
22
+ dissipation: f32,
23
+ curlStrength: f32,
24
+ clearValue: f32,
25
+ aspect: f32,
26
+ radius: f32,
27
+ exposure: f32,
28
+ shading: f32,
29
+ }
30
+
31
+ @group(0) @binding(0) var<uniform> u: Uniforms;
32
+ @group(0) @binding(1) var smpLinear: sampler;
33
+ @group(0) @binding(2) var smpNearest: sampler;
34
+ @group(0) @binding(3) var tex0: texture_2d<f32>;
35
+ @group(0) @binding(4) var tex1: texture_2d<f32>;
36
+
37
+ struct VSOut {
38
+ @builtin(position) pos: vec4f,
39
+ @location(0) uv: vec2f,
40
+ }
41
+
42
+ @vertex
43
+ fn vs(@builtin(vertex_index) i: u32) -> VSOut {
44
+ // Fullscreen triangle.
45
+ var p = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
46
+ var out: VSOut;
47
+ out.pos = vec4f(p[i], 0.0, 1.0);
48
+ // Negated y so uv row order matches texture row order (WebGPU NDC +Y is
49
+ // up but texture v=0 is the top row) — without it every pass mirrors its
50
+ // input vertically. Pointer texcoords use the same top-origin convention.
51
+ out.uv = vec2f(p[i].x, -p[i].y) * 0.5 + 0.5;
52
+ return out;
53
+ }
54
+
55
+ @fragment
56
+ fn fs_copy(in: VSOut) -> @location(0) vec4f {
57
+ return textureSample(tex0, smpLinear, in.uv);
58
+ }
59
+
60
+ @fragment
61
+ fn fs_splat(in: VSOut) -> @location(0) vec4f {
62
+ var p = in.uv - u.point;
63
+ p.x = p.x * u.aspect;
64
+ let splat = exp(-dot(p, p) / u.radius) * u.color.rgb;
65
+ let base = textureSample(tex0, smpLinear, in.uv).rgb;
66
+ return vec4f(base + splat, 1.0);
67
+ }
68
+
69
+ @fragment
70
+ fn fs_curl(in: VSOut) -> @location(0) vec4f {
71
+ let vL = in.uv - vec2f(u.texelSize.x, 0.0);
72
+ let vR = in.uv + vec2f(u.texelSize.x, 0.0);
73
+ let vT = in.uv + vec2f(0.0, u.texelSize.y);
74
+ let vB = in.uv - vec2f(0.0, u.texelSize.y);
75
+ let L = textureSample(tex0, smpLinear, vL).y;
76
+ let R = textureSample(tex0, smpLinear, vR).y;
77
+ let T = textureSample(tex0, smpLinear, vT).x;
78
+ let B = textureSample(tex0, smpLinear, vB).x;
79
+ let vorticity = R - L - T + B;
80
+ return vec4f(0.5 * vorticity, 0.0, 0.0, 1.0);
81
+ }
82
+
83
+ @fragment
84
+ fn fs_vorticity(in: VSOut) -> @location(0) vec4f {
85
+ let vL = in.uv - vec2f(u.texelSize.x, 0.0);
86
+ let vR = in.uv + vec2f(u.texelSize.x, 0.0);
87
+ let vT = in.uv + vec2f(0.0, u.texelSize.y);
88
+ let vB = in.uv - vec2f(0.0, u.texelSize.y);
89
+ let L = textureSample(tex1, smpNearest, vL).x;
90
+ let R = textureSample(tex1, smpNearest, vR).x;
91
+ let T = textureSample(tex1, smpNearest, vT).x;
92
+ let B = textureSample(tex1, smpNearest, vB).x;
93
+ let C = textureSample(tex1, smpNearest, in.uv).x;
94
+
95
+ var force = 0.5 * vec2f(abs(T) - abs(B), abs(R) - abs(L));
96
+ force = force / (length(force) + 0.0001);
97
+ force = force * u.curlStrength * C;
98
+ force.y = force.y * -1.0;
99
+
100
+ var velocity = textureSample(tex0, smpLinear, in.uv).xy;
101
+ velocity = velocity + force * u.dt;
102
+ velocity = clamp(velocity, vec2f(-1000.0), vec2f(1000.0));
103
+ return vec4f(velocity, 0.0, 1.0);
104
+ }
105
+
106
+ @fragment
107
+ fn fs_divergence(in: VSOut) -> @location(0) vec4f {
108
+ let vL = in.uv - vec2f(u.texelSize.x, 0.0);
109
+ let vR = in.uv + vec2f(u.texelSize.x, 0.0);
110
+ let vT = in.uv + vec2f(0.0, u.texelSize.y);
111
+ let vB = in.uv - vec2f(0.0, u.texelSize.y);
112
+ var L = textureSample(tex0, smpLinear, vL).x;
113
+ var R = textureSample(tex0, smpLinear, vR).x;
114
+ var T = textureSample(tex0, smpLinear, vT).y;
115
+ var B = textureSample(tex0, smpLinear, vB).y;
116
+
117
+ let C = textureSample(tex0, smpLinear, in.uv).xy;
118
+ if (vL.x < 0.0) { L = -C.x; }
119
+ if (vR.x > 1.0) { R = -C.x; }
120
+ if (vT.y > 1.0) { T = -C.y; }
121
+ if (vB.y < 0.0) { B = -C.y; }
122
+
123
+ let div = 0.5 * (R - L + T - B);
124
+ return vec4f(div, 0.0, 0.0, 1.0);
125
+ }
126
+
127
+ @fragment
128
+ fn fs_clear(in: VSOut) -> @location(0) vec4f {
129
+ return u.clearValue * textureSample(tex0, smpNearest, in.uv);
130
+ }
131
+
132
+ @fragment
133
+ fn fs_pressure(in: VSOut) -> @location(0) vec4f {
134
+ let vL = in.uv - vec2f(u.texelSize.x, 0.0);
135
+ let vR = in.uv + vec2f(u.texelSize.x, 0.0);
136
+ let vT = in.uv + vec2f(0.0, u.texelSize.y);
137
+ let vB = in.uv - vec2f(0.0, u.texelSize.y);
138
+ let L = textureSample(tex0, smpNearest, vL).x;
139
+ let R = textureSample(tex0, smpNearest, vR).x;
140
+ let T = textureSample(tex0, smpNearest, vT).x;
141
+ let B = textureSample(tex0, smpNearest, vB).x;
142
+ let divergence = textureSample(tex1, smpNearest, in.uv).x;
143
+ let pressure = (L + R + B + T - divergence) * 0.25;
144
+ return vec4f(pressure, 0.0, 0.0, 1.0);
145
+ }
146
+
147
+ @fragment
148
+ fn fs_gradient(in: VSOut) -> @location(0) vec4f {
149
+ let vL = in.uv - vec2f(u.texelSize.x, 0.0);
150
+ let vR = in.uv + vec2f(u.texelSize.x, 0.0);
151
+ let vT = in.uv + vec2f(0.0, u.texelSize.y);
152
+ let vB = in.uv - vec2f(0.0, u.texelSize.y);
153
+ let L = textureSample(tex0, smpNearest, vL).x;
154
+ let R = textureSample(tex0, smpNearest, vR).x;
155
+ let T = textureSample(tex0, smpNearest, vT).x;
156
+ let B = textureSample(tex0, smpNearest, vB).x;
157
+ var velocity = textureSample(tex1, smpLinear, in.uv).xy;
158
+ velocity = velocity - vec2f(R - L, T - B);
159
+ return vec4f(velocity, 0.0, 1.0);
160
+ }
161
+
162
+ @fragment
163
+ fn fs_advection(in: VSOut) -> @location(0) vec4f {
164
+ let coord = in.uv - u.dt * textureSample(tex0, smpLinear, in.uv).xy * u.texelSize;
165
+ let result = textureSample(tex1, smpLinear, coord);
166
+ let decay = 1.0 + u.dissipation * u.dt;
167
+ return result / decay;
168
+ }
169
+
170
+ @fragment
171
+ fn fs_display(in: VSOut) -> @location(0) vec4f {
172
+ var c = textureSample(tex0, smpLinear, in.uv).rgb;
173
+
174
+ // Neighbor samples are taken unconditionally and mixed by the shading
175
+ // flag so texture sampling stays in uniform control flow.
176
+ let vL = in.uv - vec2f(u.texelSize.x, 0.0);
177
+ let vR = in.uv + vec2f(u.texelSize.x, 0.0);
178
+ let vT = in.uv + vec2f(0.0, u.texelSize.y);
179
+ let vB = in.uv - vec2f(0.0, u.texelSize.y);
180
+ let lc = textureSample(tex0, smpLinear, vL).rgb;
181
+ let rc = textureSample(tex0, smpLinear, vR).rgb;
182
+ let tc = textureSample(tex0, smpLinear, vT).rgb;
183
+ let bc = textureSample(tex0, smpLinear, vB).rgb;
184
+ let dx = length(rc) - length(lc);
185
+ let dy = length(tc) - length(bc);
186
+ let n = normalize(vec3f(dx, dy, length(u.texelSize)));
187
+ let l = vec3f(0.0, 0.0, 1.0);
188
+ let diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);
189
+ c = c * mix(1.0, diffuse, u.shading);
190
+
191
+ // The canvas color space keeps the sRGB transfer function even with a
192
+ // float16 format (it only adds range/precision), so values are written
193
+ // as-is like the WebGL path — no gamma conversion. The exposure boost
194
+ // pushes encoded values above 1.0, which extended tone mapping renders
195
+ // brighter than SDR white. Alpha stays clamped for premultiplied
196
+ // compositing; color intentionally exceeds it for the HDR glow.
197
+ let a = min(max(c.r, max(c.g, c.b)), 1.0);
198
+ let boosted = max(c, vec3f(0.0)) * u.exposure;
199
+ return vec4f(boosted, a);
200
+ }
201
+ `;
202
+ // Float offsets inside the 64-byte Uniforms struct.
203
+ const U_FLOATS = 16;
204
+ const OFF_TEXEL = 0;
205
+ const OFF_POINT = 2;
206
+ const OFF_COLOR = 4;
207
+ const OFF_DT = 8;
208
+ const OFF_DISSIPATION = 9;
209
+ const OFF_CURL = 10;
210
+ const OFF_CLEAR = 11;
211
+ const OFF_ASPECT = 12;
212
+ const OFF_RADIUS = 13;
213
+ const OFF_EXPOSURE = 14;
214
+ const OFF_SHADING = 15;
215
+ function scaleByPixelRatio(input) {
216
+ const pixelRatio = window.devicePixelRatio || 1;
217
+ return Math.floor(input * pixelRatio);
218
+ }
219
+ async function createWebGpuFluid(canvas, opts) {
220
+ try {
221
+ const gpu = navigator.gpu;
222
+ if (!gpu)
223
+ return null;
224
+ const adapter = await gpu.requestAdapter();
225
+ if (!adapter)
226
+ return null;
227
+ const device = await adapter.requestDevice();
228
+ const format = "rgba16float";
229
+ // `toneMapping` and `colorSpace` are ignored by browsers that do not
230
+ // support them yet (WebIDL drops unknown dictionary members), in which
231
+ // case output is clamped to SDR.
232
+ const configuration = {
233
+ device,
234
+ format,
235
+ alphaMode: "premultiplied",
236
+ colorSpace: "display-p3",
237
+ toneMapping: { mode: "extended" },
238
+ };
239
+ // Probe the configuration on a detached canvas first. Once a canvas
240
+ // has vended a "webgpu" context it can never provide a WebGL one, so
241
+ // claiming the visible canvas before knowing configure() succeeds
242
+ // would break the advertised WebGL fallback.
243
+ try {
244
+ const probe = document.createElement("canvas").getContext("webgpu");
245
+ if (!probe) {
246
+ device.destroy();
247
+ return null;
248
+ }
249
+ probe.configure(configuration);
250
+ probe.unconfigure();
251
+ }
252
+ catch (error) {
253
+ device.destroy();
254
+ if (import.meta.env.DEV) {
255
+ console.warn("[FluidCursor] WebGPU canvas configuration rejected:", error);
256
+ }
257
+ return null;
258
+ }
259
+ const maybeContext = canvas.getContext("webgpu");
260
+ if (!maybeContext) {
261
+ device.destroy();
262
+ return null;
263
+ }
264
+ const context = maybeContext;
265
+ context.configure(configuration);
266
+ // Browsers that do not support toneMapping drop the member, which
267
+ // getConfiguration() makes observable. Surface it as data (not just a
268
+ // DEV log) so callers can tell an extended-tone-mapping canvas from a
269
+ // clamped fallback; whether the display is HDR is checked separately by
270
+ // startWebGpuFluid.
271
+ let extendedToneMapping = false;
272
+ try {
273
+ const applied = context.getConfiguration?.();
274
+ extendedToneMapping = applied?.toneMapping?.mode === "extended";
275
+ }
276
+ catch {
277
+ // getConfiguration unsupported (e.g. Safari): assume clamped output.
278
+ }
279
+ if (import.meta.env.DEV) {
280
+ console.info(extendedToneMapping
281
+ ? "[FluidCursor] extended tone mapping active (webgpu-hdr if the display reports high dynamic range)"
282
+ : "[FluidCursor] HDR level: webgpu-sdr (float16 + P3, tone mapping clamped by browser)");
283
+ }
284
+ let destroyed = false;
285
+ let lost = false;
286
+ device.lost.then((info) => {
287
+ lost = true;
288
+ if (!destroyed && import.meta.env.DEV) {
289
+ console.warn(`[FluidCursor] WebGPU device lost (${info.reason}): ${info.message}`);
290
+ }
291
+ });
292
+ if (import.meta.env.DEV) {
293
+ device.addEventListener("uncapturederror", (event) => {
294
+ console.error("[FluidCursor] WebGPU uncaptured error:", event.error.message);
295
+ });
296
+ }
297
+ const module = device.createShaderModule({ code: WGSL });
298
+ const bindGroupLayout = device.createBindGroupLayout({
299
+ entries: [
300
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
301
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
302
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
303
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
304
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
305
+ ],
306
+ });
307
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
308
+ function makePipeline(entryPoint, targetFormat) {
309
+ return device.createRenderPipeline({
310
+ layout: pipelineLayout,
311
+ vertex: { module, entryPoint: "vs" },
312
+ fragment: { module, entryPoint, targets: [{ format: targetFormat }] },
313
+ primitive: { topology: "triangle-list" },
314
+ });
315
+ }
316
+ const VEL_FORMAT = "rg16float";
317
+ const SCALAR_FORMAT = "r16float";
318
+ const DYE_FORMAT = "rgba16float";
319
+ const pipelines = {
320
+ splatVel: makePipeline("fs_splat", VEL_FORMAT),
321
+ splatDye: makePipeline("fs_splat", DYE_FORMAT),
322
+ curl: makePipeline("fs_curl", SCALAR_FORMAT),
323
+ vorticity: makePipeline("fs_vorticity", VEL_FORMAT),
324
+ divergence: makePipeline("fs_divergence", SCALAR_FORMAT),
325
+ clear: makePipeline("fs_clear", SCALAR_FORMAT),
326
+ pressure: makePipeline("fs_pressure", SCALAR_FORMAT),
327
+ gradient: makePipeline("fs_gradient", VEL_FORMAT),
328
+ advectVel: makePipeline("fs_advection", VEL_FORMAT),
329
+ advectDye: makePipeline("fs_advection", DYE_FORMAT),
330
+ display: makePipeline("fs_display", format),
331
+ copyVel: makePipeline("fs_copy", VEL_FORMAT),
332
+ copyDye: makePipeline("fs_copy", DYE_FORMAT),
333
+ };
334
+ const smpLinear = device.createSampler({
335
+ magFilter: "linear",
336
+ minFilter: "linear",
337
+ addressModeU: "clamp-to-edge",
338
+ addressModeV: "clamp-to-edge",
339
+ });
340
+ const smpNearest = device.createSampler({
341
+ magFilter: "nearest",
342
+ minFilter: "nearest",
343
+ addressModeU: "clamp-to-edge",
344
+ addressModeV: "clamp-to-edge",
345
+ });
346
+ function makeUniformBuffer() {
347
+ return device.createBuffer({
348
+ size: U_FLOATS * 4,
349
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
350
+ });
351
+ }
352
+ const uSplatVel = makeUniformBuffer();
353
+ const uSplatDye = makeUniformBuffer();
354
+ const uCurl = makeUniformBuffer();
355
+ const uVorticity = makeUniformBuffer();
356
+ const uDivergence = makeUniformBuffer();
357
+ const uClear = makeUniformBuffer();
358
+ const uPressure = makeUniformBuffer();
359
+ const uGradient = makeUniformBuffer();
360
+ const uAdvectVel = makeUniformBuffer();
361
+ const uAdvectDye = makeUniformBuffer();
362
+ const uDisplay = makeUniformBuffer();
363
+ const uCopy = makeUniformBuffer();
364
+ const scratch = new Float32Array(U_FLOATS);
365
+ function writeUniforms(buffer, v) {
366
+ scratch.fill(0);
367
+ if (v.texelSize) {
368
+ scratch[OFF_TEXEL] = v.texelSize[0];
369
+ scratch[OFF_TEXEL + 1] = v.texelSize[1];
370
+ }
371
+ if (v.point) {
372
+ scratch[OFF_POINT] = v.point[0];
373
+ scratch[OFF_POINT + 1] = v.point[1];
374
+ }
375
+ if (v.color) {
376
+ scratch[OFF_COLOR] = v.color[0];
377
+ scratch[OFF_COLOR + 1] = v.color[1];
378
+ scratch[OFF_COLOR + 2] = v.color[2];
379
+ scratch[OFF_COLOR + 3] = 1;
380
+ }
381
+ scratch[OFF_DT] = v.dt ?? 0;
382
+ scratch[OFF_DISSIPATION] = v.dissipation ?? 0;
383
+ scratch[OFF_CURL] = v.curlStrength ?? 0;
384
+ scratch[OFF_CLEAR] = v.clearValue ?? 0;
385
+ scratch[OFF_ASPECT] = v.aspect ?? 1;
386
+ scratch[OFF_RADIUS] = v.radius ?? 1;
387
+ scratch[OFF_EXPOSURE] = v.exposure ?? 1;
388
+ scratch[OFF_SHADING] = v.shading ?? 0;
389
+ device.queue.writeBuffer(buffer, 0, scratch);
390
+ }
391
+ function makeTexture(w, h, texFormat) {
392
+ return device.createTexture({
393
+ size: { width: w, height: h },
394
+ format: texFormat,
395
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
396
+ });
397
+ }
398
+ function makeDoubleTex(w, h, texFormat) {
399
+ const a = makeTexture(w, h, texFormat);
400
+ const b = makeTexture(w, h, texFormat);
401
+ const target = {
402
+ read: a,
403
+ write: b,
404
+ readView: a.createView(),
405
+ writeView: b.createView(),
406
+ width: w,
407
+ height: h,
408
+ texelSizeX: 1 / w,
409
+ texelSizeY: 1 / h,
410
+ swap() {
411
+ const t = this.read;
412
+ const tv = this.readView;
413
+ this.read = this.write;
414
+ this.readView = this.writeView;
415
+ this.write = t;
416
+ this.writeView = tv;
417
+ },
418
+ };
419
+ return target;
420
+ }
421
+ // Bind groups are cached per (uniform buffer, texture views) combination
422
+ // — the ping-pong swaps only cycle through a small finite set, and
423
+ // recreating ~30 bind groups per frame (20 of them for the pressure
424
+ // iterations) is measurable allocation/validation overhead. The cache
425
+ // is cleared whenever textures are recreated (resize).
426
+ const bindGroupCache = new Map();
427
+ const resourceIds = new WeakMap();
428
+ let nextResourceId = 0;
429
+ function idOf(resource) {
430
+ let id = resourceIds.get(resource);
431
+ if (id === undefined) {
432
+ id = nextResourceId++;
433
+ resourceIds.set(resource, id);
434
+ }
435
+ return id;
436
+ }
437
+ function getBindGroup(uniforms, tex0, tex1) {
438
+ const key = `${idOf(uniforms)}:${idOf(tex0)}:${idOf(tex1)}`;
439
+ let bindGroup = bindGroupCache.get(key);
440
+ if (!bindGroup) {
441
+ bindGroup = device.createBindGroup({
442
+ layout: bindGroupLayout,
443
+ entries: [
444
+ { binding: 0, resource: { buffer: uniforms } },
445
+ { binding: 1, resource: smpLinear },
446
+ { binding: 2, resource: smpNearest },
447
+ { binding: 3, resource: tex0 },
448
+ { binding: 4, resource: tex1 },
449
+ ],
450
+ });
451
+ bindGroupCache.set(key, bindGroup);
452
+ }
453
+ return bindGroup;
454
+ }
455
+ function runPass(encoder, pipeline, uniforms, tex0, tex1, target) {
456
+ const bindGroup = getBindGroup(uniforms, tex0, tex1);
457
+ const pass = encoder.beginRenderPass({
458
+ colorAttachments: [
459
+ {
460
+ view: target,
461
+ loadOp: "clear",
462
+ storeOp: "store",
463
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
464
+ },
465
+ ],
466
+ });
467
+ pass.setPipeline(pipeline);
468
+ pass.setBindGroup(0, bindGroup);
469
+ pass.draw(3);
470
+ pass.end();
471
+ }
472
+ // --- Simulation state ---
473
+ let dye = null;
474
+ let velocity = null;
475
+ let pressureTex = null;
476
+ let divergenceTex = null;
477
+ let divergenceView = null;
478
+ let curlTex = null;
479
+ let curlView = null;
480
+ function initTextures() {
481
+ const simRes = getSimResolution(opts.simResolution, canvas.width, canvas.height);
482
+ const dyeRes = getSimResolution(opts.dyeResolution, canvas.width, canvas.height);
483
+ const oldDye = dye;
484
+ const oldVelocity = velocity;
485
+ // Canvas resize that leaves grid sizes unchanged (aspect-preserving):
486
+ // keep the existing textures (mirrors resizeDoubleFBO's early-out).
487
+ if (oldDye &&
488
+ oldVelocity &&
489
+ oldDye.width === dyeRes.width &&
490
+ oldDye.height === dyeRes.height &&
491
+ oldVelocity.width === simRes.width &&
492
+ oldVelocity.height === simRes.height) {
493
+ return;
494
+ }
495
+ const newDye = makeDoubleTex(dyeRes.width, dyeRes.height, DYE_FORMAT);
496
+ const newVelocity = makeDoubleTex(simRes.width, simRes.height, VEL_FORMAT);
497
+ // Preserve existing fluid across resizes by resampling the old
498
+ // read textures into the new ones (mirrors resizeDoubleFBO).
499
+ if (oldDye && oldVelocity) {
500
+ writeUniforms(uCopy, {});
501
+ const encoder = device.createCommandEncoder();
502
+ runPass(encoder, pipelines.copyDye, uCopy, oldDye.readView, oldDye.readView, newDye.readView);
503
+ runPass(encoder, pipelines.copyVel, uCopy, oldVelocity.readView, oldVelocity.readView, newVelocity.readView);
504
+ device.queue.submit([encoder.finish()]);
505
+ oldDye.read.destroy();
506
+ oldDye.write.destroy();
507
+ oldVelocity.read.destroy();
508
+ oldVelocity.write.destroy();
509
+ }
510
+ dye = newDye;
511
+ velocity = newVelocity;
512
+ // Cached bind groups reference the destroyed texture views.
513
+ bindGroupCache.clear();
514
+ pressureTex?.read.destroy();
515
+ pressureTex?.write.destroy();
516
+ divergenceTex?.destroy();
517
+ curlTex?.destroy();
518
+ pressureTex = makeDoubleTex(simRes.width, simRes.height, SCALAR_FORMAT);
519
+ divergenceTex = makeTexture(simRes.width, simRes.height, SCALAR_FORMAT);
520
+ divergenceView = divergenceTex.createView();
521
+ curlTex = makeTexture(simRes.width, simRes.height, SCALAR_FORMAT);
522
+ curlView = curlTex.createView();
523
+ }
524
+ function resizeIfNeeded() {
525
+ if (destroyed || lost)
526
+ return;
527
+ const width = scaleByPixelRatio(canvas.clientWidth);
528
+ const height = scaleByPixelRatio(canvas.clientHeight);
529
+ if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {
530
+ canvas.width = width;
531
+ canvas.height = height;
532
+ initTextures();
533
+ }
534
+ }
535
+ function splat(x, y, dx, dy, color) {
536
+ if (destroyed || lost || !velocity || !dye)
537
+ return;
538
+ let radius = correctRadius(opts.splatRadius / 100, canvas.width, canvas.height);
539
+ if (opts.contained) {
540
+ radius = scaleRadiusForContainer(radius, canvas.clientHeight, window.innerHeight);
541
+ }
542
+ const aspect = canvas.width / canvas.height;
543
+ writeUniforms(uSplatVel, {
544
+ point: [x, y],
545
+ color: [dx, dy, 0],
546
+ aspect,
547
+ radius,
548
+ });
549
+ writeUniforms(uSplatDye, {
550
+ point: [x, y],
551
+ color: [color.r, color.g, color.b],
552
+ aspect,
553
+ radius,
554
+ });
555
+ const encoder = device.createCommandEncoder();
556
+ runPass(encoder, pipelines.splatVel, uSplatVel, velocity.readView, velocity.readView, velocity.writeView);
557
+ velocity.swap();
558
+ runPass(encoder, pipelines.splatDye, uSplatDye, dye.readView, dye.readView, dye.writeView);
559
+ dye.swap();
560
+ device.queue.submit([encoder.finish()]);
561
+ }
562
+ function frame(dt) {
563
+ if (destroyed || lost || !velocity || !dye || !pressureTex || !divergenceView || !curlView) {
564
+ return;
565
+ }
566
+ const velTexel = [velocity.texelSizeX, velocity.texelSizeY];
567
+ writeUniforms(uCurl, { texelSize: velTexel });
568
+ writeUniforms(uVorticity, { texelSize: velTexel, curlStrength: opts.curl, dt });
569
+ writeUniforms(uDivergence, { texelSize: velTexel });
570
+ writeUniforms(uClear, { clearValue: opts.pressure });
571
+ writeUniforms(uPressure, { texelSize: velTexel });
572
+ writeUniforms(uGradient, { texelSize: velTexel });
573
+ writeUniforms(uAdvectVel, {
574
+ texelSize: velTexel,
575
+ dt,
576
+ dissipation: opts.velocityDissipation,
577
+ });
578
+ writeUniforms(uAdvectDye, {
579
+ texelSize: velTexel,
580
+ dt,
581
+ dissipation: opts.densityDissipation,
582
+ });
583
+ writeUniforms(uDisplay, {
584
+ texelSize: [1 / canvas.width, 1 / canvas.height],
585
+ exposure: opts.exposure,
586
+ shading: opts.shading ? 1 : 0,
587
+ });
588
+ const encoder = device.createCommandEncoder();
589
+ runPass(encoder, pipelines.curl, uCurl, velocity.readView, velocity.readView, curlView);
590
+ runPass(encoder, pipelines.vorticity, uVorticity, velocity.readView, curlView, velocity.writeView);
591
+ velocity.swap();
592
+ runPass(encoder, pipelines.divergence, uDivergence, velocity.readView, velocity.readView, divergenceView);
593
+ runPass(encoder, pipelines.clear, uClear, pressureTex.readView, pressureTex.readView, pressureTex.writeView);
594
+ pressureTex.swap();
595
+ for (let i = 0; i < opts.pressureIterations; i++) {
596
+ runPass(encoder, pipelines.pressure, uPressure, pressureTex.readView, divergenceView, pressureTex.writeView);
597
+ pressureTex.swap();
598
+ }
599
+ runPass(encoder, pipelines.gradient, uGradient, pressureTex.readView, velocity.readView, velocity.writeView);
600
+ velocity.swap();
601
+ runPass(encoder, pipelines.advectVel, uAdvectVel, velocity.readView, velocity.readView, velocity.writeView);
602
+ velocity.swap();
603
+ runPass(encoder, pipelines.advectDye, uAdvectDye, velocity.readView, dye.readView, dye.writeView);
604
+ dye.swap();
605
+ runPass(encoder, pipelines.display, uDisplay, dye.readView, dye.readView, context.getCurrentTexture().createView());
606
+ device.queue.submit([encoder.finish()]);
607
+ }
608
+ initTextures();
609
+ return {
610
+ splat,
611
+ resizeIfNeeded,
612
+ frame,
613
+ get lost() {
614
+ return lost;
615
+ },
616
+ extendedToneMapping,
617
+ destroy() {
618
+ destroyed = true;
619
+ device.destroy();
620
+ },
621
+ };
622
+ }
623
+ catch (error) {
624
+ if (import.meta.env.DEV) {
625
+ console.warn("[FluidCursor] WebGPU init failed, falling back to WebGL:", error);
626
+ }
627
+ return null;
628
+ }
629
+ }
630
+ /**
631
+ * Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
632
+ * visibility pause + auto splats. Resolves with a {@link FluidCursorHandle}
633
+ * (augmented with `cleanup`), or with `null` when WebGPU is unavailable
634
+ * (caller falls back to the WebGL path).
635
+ *
636
+ * Handle coordinates are top-left origin in [0,1]; WebGPU texcoords already
637
+ * use a top-origin convention, so no Y flip happens here (the WebGL path owns
638
+ * its own flip).
639
+ */
640
+ export async function startWebGpuFluid(canvas, opts) {
641
+ const maybeEngine = await createWebGpuFluid(canvas, opts);
642
+ if (!maybeEngine)
643
+ return null;
644
+ const engine = maybeEngine;
645
+ const pointers = [pointerPrototype()];
646
+ // Dedicated synthetic pointer driven programmatically through the handle. It
647
+ // lives alongside the mouse pointer in the same list, so updateFrame splats
648
+ // both and the two inputs coexist. Created on first programmatic use, so a
649
+ // consumer that never drives the handle costs nothing.
650
+ let autopilotPointer = null;
651
+ let penWasUp = true;
652
+ function ensureAutopilotPointer() {
653
+ if (!autopilotPointer) {
654
+ autopilotPointer = pointerPrototype();
655
+ pointers.push(autopilotPointer);
656
+ }
657
+ return autopilotPointer;
658
+ }
659
+ let animationFrameId = 0;
660
+ let lastUpdateTime = Date.now();
661
+ let colorUpdateTimer = 0;
662
+ let isVisible = true;
663
+ let stopped = false;
664
+ // --- Pointer position mapping (mirrors the WebGL path) ---
665
+ let canvasRectCache = null;
666
+ function updateCanvasRectCache() {
667
+ if (opts.contained)
668
+ canvasRectCache = canvas.getBoundingClientRect();
669
+ }
670
+ function getCanvasPos(clientX, clientY) {
671
+ if (opts.contained) {
672
+ if (!canvasRectCache)
673
+ updateCanvasRectCache();
674
+ const rect = canvasRectCache;
675
+ return {
676
+ x: scaleByPixelRatio(clientX - rect.left),
677
+ y: scaleByPixelRatio(clientY - rect.top),
678
+ };
679
+ }
680
+ return { x: scaleByPixelRatio(clientX), y: scaleByPixelRatio(clientY) };
681
+ }
682
+ function updatePointerDownData(pointer, id, posX, posY) {
683
+ pointer.id = id;
684
+ pointer.down = true;
685
+ pointer.moved = false;
686
+ pointer.texcoordX = posX / canvas.width;
687
+ pointer.texcoordY = posY / canvas.height;
688
+ pointer.prevTexcoordX = pointer.texcoordX;
689
+ pointer.prevTexcoordY = pointer.texcoordY;
690
+ pointer.deltaX = 0;
691
+ pointer.deltaY = 0;
692
+ pointer.color = opts.generateColor();
693
+ }
694
+ function updatePointerMoveData(pointer, posX, posY, color) {
695
+ pointer.prevTexcoordX = pointer.texcoordX;
696
+ pointer.prevTexcoordY = pointer.texcoordY;
697
+ pointer.texcoordX = posX / canvas.width;
698
+ pointer.texcoordY = posY / canvas.height;
699
+ pointer.deltaX = correctDeltaX(pointer.texcoordX - pointer.prevTexcoordX, canvas.width, canvas.height);
700
+ pointer.deltaY = correctDeltaY(pointer.texcoordY - pointer.prevTexcoordY, canvas.width, canvas.height);
701
+ pointer.moved = Math.abs(pointer.deltaX) > 0 || Math.abs(pointer.deltaY) > 0;
702
+ pointer.color = color;
703
+ }
704
+ function splatPointer(pointer) {
705
+ const dx = pointer.deltaX * opts.splatForce;
706
+ const dy = pointer.deltaY * opts.splatForce;
707
+ engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
708
+ }
709
+ function clickSplat(pointer) {
710
+ const color = opts.generateColor();
711
+ color.r *= 10;
712
+ color.g *= 10;
713
+ color.b *= 10;
714
+ const dx = 10 * (Math.random() - 0.5);
715
+ const dy = 30 * (Math.random() - 0.5);
716
+ engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
717
+ }
718
+ function multipleSplats(steps) {
719
+ // Simulate a cursor sweep along a random arc — each step on its own
720
+ // frame so the velocity field has time to evolve between injections.
721
+ const angle = Math.random() * Math.PI * 2;
722
+ const cx = 0.25 + Math.random() * 0.5;
723
+ const cy = 0.25 + Math.random() * 0.5;
724
+ const arcSpan = Math.PI * (0.8 + Math.random() * 0.8);
725
+ let i = 0;
726
+ function step() {
727
+ if (stopped || i >= steps)
728
+ return;
729
+ const t = i / Math.max(1, steps - 1);
730
+ const a = angle + t * arcSpan;
731
+ const r = 0.15 + 0.05 * Math.sin(t * Math.PI);
732
+ const x = cx + Math.cos(a) * r;
733
+ const y = cy + Math.sin(a) * r;
734
+ const color = opts.generateColor();
735
+ engine.splat(x, y, -Math.sin(a) * opts.splatForce * 0.5, Math.cos(a) * opts.splatForce * 0.5, color);
736
+ i++;
737
+ if (i < steps)
738
+ requestAnimationFrame(step);
739
+ }
740
+ requestAnimationFrame(step);
741
+ }
742
+ // --- Frame loop (same ordering as the WebGL path: resize, colors,
743
+ // inputs, then simulation step + render) ---
744
+ function updateFrame() {
745
+ if (stopped || !isVisible)
746
+ return;
747
+ // Device loss (GPU reset, driver update): stop the loop instead of
748
+ // spinning on a dead device. The effect simply disappears.
749
+ if (engine.lost)
750
+ return;
751
+ const now = Date.now();
752
+ const dt = Math.min((now - lastUpdateTime) / 1000, 0.016666);
753
+ lastUpdateTime = now;
754
+ engine.resizeIfNeeded();
755
+ colorUpdateTimer += dt * opts.colorUpdateSpeed;
756
+ if (colorUpdateTimer >= 1) {
757
+ colorUpdateTimer = colorUpdateTimer % 1;
758
+ pointers.forEach((p) => {
759
+ // The synthetic pointer's color belongs to the handle: pulling from
760
+ // the generator here would advance the shared palette index twice per
761
+ // update and overwrite explicit stroke colors.
762
+ if (p === autopilotPointer)
763
+ return;
764
+ p.color = opts.generateColor();
765
+ });
766
+ }
767
+ for (const p of pointers) {
768
+ if (p.moved) {
769
+ p.moved = false;
770
+ splatPointer(p);
771
+ }
772
+ }
773
+ engine.frame(dt);
774
+ animationFrameId = requestAnimationFrame(updateFrame);
775
+ }
776
+ // --- Event listeners ---
777
+ function handleMouseDown(e) {
778
+ const pointer = pointers[0];
779
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
780
+ updatePointerDownData(pointer, -1, posX, posY);
781
+ clickSplat(pointer);
782
+ }
783
+ function handleFirstMouseMove(e) {
784
+ const pointer = pointers[0];
785
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
786
+ updatePointerMoveData(pointer, posX, posY, opts.generateColor());
787
+ document.body.removeEventListener("mousemove", handleFirstMouseMove);
788
+ }
789
+ function handleMouseMove(e) {
790
+ const pointer = pointers[0];
791
+ const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
792
+ updatePointerMoveData(pointer, posX, posY, pointer.color);
793
+ }
794
+ function handleFirstTouchStart(e) {
795
+ const touches = e.targetTouches;
796
+ const pointer = pointers[0];
797
+ for (let i = 0; i < touches.length; i++) {
798
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
799
+ updatePointerDownData(pointer, touches[i].identifier, posX, posY);
800
+ }
801
+ document.body.removeEventListener("touchstart", handleFirstTouchStart);
802
+ }
803
+ function handleTouchStart(e) {
804
+ const touches = e.targetTouches;
805
+ const pointer = pointers[0];
806
+ for (let i = 0; i < touches.length; i++) {
807
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
808
+ updatePointerDownData(pointer, touches[i].identifier, posX, posY);
809
+ }
810
+ }
811
+ function handleTouchMove(e) {
812
+ const touches = e.targetTouches;
813
+ const pointer = pointers[0];
814
+ for (let i = 0; i < touches.length; i++) {
815
+ const { x: posX, y: posY } = getCanvasPos(touches[i].clientX, touches[i].clientY);
816
+ updatePointerMoveData(pointer, posX, posY, pointer.color);
817
+ }
818
+ }
819
+ if (opts.contained) {
820
+ window.addEventListener("resize", updateCanvasRectCache, { passive: true });
821
+ window.addEventListener("scroll", updateCanvasRectCache, { passive: true });
822
+ updateCanvasRectCache();
823
+ }
824
+ if (opts.interactive) {
825
+ window.addEventListener("mousedown", handleMouseDown);
826
+ document.body.addEventListener("mousemove", handleFirstMouseMove);
827
+ window.addEventListener("mousemove", handleMouseMove);
828
+ document.body.addEventListener("touchstart", handleFirstTouchStart);
829
+ window.addEventListener("touchstart", handleTouchStart, false);
830
+ window.addEventListener("touchmove", handleTouchMove, false);
831
+ }
832
+ let observer = null;
833
+ if (opts.pauseWhenHidden) {
834
+ observer = new IntersectionObserver(([entry]) => {
835
+ const wasVisible = isVisible;
836
+ isVisible = entry.isIntersecting;
837
+ if (isVisible && !wasVisible && !stopped) {
838
+ lastUpdateTime = Date.now();
839
+ animationFrameId = requestAnimationFrame(updateFrame);
840
+ }
841
+ }, { threshold: 0 });
842
+ observer.observe(canvas);
843
+ }
844
+ updateFrame();
845
+ if (opts.splatOnMount)
846
+ multipleSplats(Math.floor(Math.random() * 6) + 10);
847
+ let autoSplatTimer = null;
848
+ if (opts.autoSplat) {
849
+ autoSplatTimer = setInterval(() => {
850
+ engine.splat(Math.random(), Math.random(), (Math.random() - 0.5) * 200, (Math.random() - 0.5) * 200, opts.generateColor());
851
+ }, opts.autoSplatInterval);
852
+ }
853
+ // Extended tone mapping only proves the browser kept the configuration — an
854
+ // SDR display still clamps the output — so "webgpu-hdr" requires both. The
855
+ // display state is sampled once here and does not follow the window to
856
+ // another monitor.
857
+ const hdrDisplay = typeof window !== "undefined" && typeof window.matchMedia === "function"
858
+ ? window.matchMedia("(dynamic-range: high)").matches
859
+ : false;
860
+ const renderLevel = engine.extendedToneMapping && hdrDisplay ? "webgpu-hdr" : "webgpu-sdr";
861
+ return {
862
+ /**
863
+ * x,y in [0,1], top-left origin. WebGPU texcoords are already top-origin,
864
+ * so the coordinates pass straight through.
865
+ */
866
+ moveTo(x, y, color) {
867
+ const pointer = ensureAutopilotPointer();
868
+ const posX = x * canvas.width;
869
+ const posY = y * canvas.height;
870
+ if (penWasUp) {
871
+ // First move of a new stroke: reposition only (mirrors
872
+ // updatePointerDownData) so the jump from the previous stroke's
873
+ // end never draws a connecting streak.
874
+ penWasUp = false;
875
+ pointer.down = true;
876
+ pointer.moved = false;
877
+ pointer.texcoordX = posX / canvas.width;
878
+ pointer.texcoordY = posY / canvas.height;
879
+ pointer.prevTexcoordX = pointer.texcoordX;
880
+ pointer.prevTexcoordY = pointer.texcoordY;
881
+ pointer.deltaX = 0;
882
+ pointer.deltaY = 0;
883
+ // A stroke with no explicit color takes a fresh generated one, as a
884
+ // real pointer-down does: the prototype's black would inject
885
+ // invisible dye.
886
+ pointer.color = color ?? opts.generateColor();
887
+ return;
888
+ }
889
+ updatePointerMoveData(pointer, posX, posY, color ?? pointer.color);
890
+ },
891
+ penUp() {
892
+ // `moved` is deliberately left alone: updateFrame consumes and clears it
893
+ // on the next frame, so clearing it here would swallow the final segment
894
+ // of a stroke finished synchronously before that frame.
895
+ penWasUp = true;
896
+ if (autopilotPointer)
897
+ autopilotPointer.down = false;
898
+ },
899
+ /** No Y flip on the WebGPU path. */
900
+ burst(x, y, dx, dy, color) {
901
+ engine.splat(x, y, dx, dy, color);
902
+ },
903
+ renderLevel,
904
+ cleanup() {
905
+ stopped = true;
906
+ cancelAnimationFrame(animationFrameId);
907
+ if (observer)
908
+ observer.disconnect();
909
+ if (autoSplatTimer)
910
+ clearInterval(autoSplatTimer);
911
+ if (opts.interactive) {
912
+ window.removeEventListener("mousedown", handleMouseDown);
913
+ document.body.removeEventListener("mousemove", handleFirstMouseMove);
914
+ window.removeEventListener("mousemove", handleMouseMove);
915
+ document.body.removeEventListener("touchstart", handleFirstTouchStart);
916
+ window.removeEventListener("touchstart", handleTouchStart);
917
+ window.removeEventListener("touchmove", handleTouchMove);
918
+ }
919
+ if (opts.contained) {
920
+ window.removeEventListener("resize", updateCanvasRectCache);
921
+ window.removeEventListener("scroll", updateCanvasRectCache);
922
+ }
923
+ engine.destroy();
924
+ },
925
+ };
926
+ }