{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"fluid-cursor","type":"registry:ui","title":"FluidCursor","description":"Full Navier-Stokes fluid simulation that swirls color in response to cursor and touch movement, running on WebGL2 with a WebGL1 fallback and an optional WebGPU HDR path for wide-gamut, brighter-than-white glow","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["effects"],"devDependencies":["@webgpu/types@^0.1.71"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/fluid-cursor/FluidCursor.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { REDUCED_MOTION_QUERY, useReducedMotion } from \"../../internals/motion/media-query\";\nimport { cn } from \"../../utils\";\nimport {\n\ttype ColorRGB,\n\ttype Pointer,\n\ttype FluidCursorHandle,\n\ttype FluidRenderLevel,\n\tpointerPrototype,\n\thexToRgb,\n\tHSVtoRGB,\n\tscaleRadiusForContainer,\n\twrap,\n\tclickBoost,\n} from \"./fluid-shared\";\nimport { isDev } from \"./dev\";\nimport { startWebGpuFluid } from \"./webgpu-engine\";\n\nexport interface FluidCursorProps {\n\tsimResolution?: number;\n\tdyeResolution?: number;\n\tcaptureResolution?: number;\n\tdensityDissipation?: number;\n\tvelocityDissipation?: number;\n\tpressure?: number;\n\tpressureIterations?: number;\n\tcurl?: number;\n\tsplatRadius?: number;\n\tsplatForce?: number;\n\tshading?: boolean;\n\tcolorUpdateSpeed?: number;\n\tbackColor?: ColorRGB | string;\n\ttransparent?: boolean;\n\tfluidColor?: string;\n\tfluidColors?: string[];\n\tcolorIntensity?: number;\n\tclassName?: string;\n\tautoSplat?: boolean;\n\tautoSplatInterval?: number;\n\tinteractive?: boolean;\n\tpauseWhenHidden?: boolean;\n\tsplatOnMount?: boolean;\n\tallowMultiple?: boolean;\n\tcontained?: boolean;\n\thdr?: boolean;\n\thdrBoost?: number;\n\t/**\n\t * Experimental: render the fluid as a retro ordered-dither bitmap.\n\t * The dye is snapped to a chunky pixel grid and each color channel is\n\t * quantized against a procedural 4x4 Bayer matrix, so dot density\n\t * encodes brightness while hues are preserved. Forces the WebGL\n\t * renderer (`hdr` is ignored while set). Applied at mount, like the\n\t * other simulation props.\n\t */\n\tdither?: boolean;\n\t/** Size of one dithered pixel in CSS pixels (minimum 1). */\n\tditherPixelSize?: number;\n\t/** Color levels per channel in dither mode, clamped to [2, 16]. */\n\tditherLevels?: number;\n\t/**\n\t * Called once the fluid engine is live, with an imperative handle to\n\t * drive the simulation programmatically (trace a path via\n\t * `moveTo`/`penUp`, fire a one-off `burst`) and read back the actual\n\t * `renderLevel`. Pair with `interactive={false}` to drive it entirely\n\t * without a real cursor.\n\t */\n\tonReady?: (handle: FluidCursorHandle) => void;\n}\n\n/** The reduced-motion preference as the browser reports it right now. */\nfunction prefersReducedMotionNow(): boolean {\n\treturn (\n\t\ttypeof window !== \"undefined\" &&\n\t\ttypeof window.matchMedia === \"function\" &&\n\t\twindow.matchMedia(REDUCED_MOTION_QUERY).matches\n\t);\n}\n\nexport function FluidCursor({\n\tsimResolution = 128,\n\tdyeResolution = 1440,\n\tcaptureResolution = 512,\n\tdensityDissipation = 3.5,\n\tvelocityDissipation = 2,\n\tpressure = 0.1,\n\tpressureIterations = 20,\n\tcurl = 3,\n\tsplatRadius = 0.2,\n\tsplatForce = 6000,\n\tshading = true,\n\tcolorUpdateSpeed = 10,\n\tbackColor = { r: 0.5, g: 0, b: 0 },\n\ttransparent = true,\n\tfluidColor,\n\tfluidColors,\n\tcolorIntensity = 0.15,\n\tclassName = \"\",\n\tautoSplat = false,\n\tautoSplatInterval = 1500,\n\tinteractive = true,\n\tpauseWhenHidden = true,\n\tsplatOnMount = false,\n\tallowMultiple = false,\n\tcontained = true,\n\thdr = false,\n\thdrBoost = 1.5,\n\tdither = false,\n\tditherPixelSize = 3,\n\tditherLevels = 4,\n\tonReady,\n}: FluidCursorProps) {\n\t// The Svelte source derives these with plain `const`s in the instance\n\t// script, so they are computed once at init and a later prop change never\n\t// re-derives them. A lazy state initializer reproduces that exactly — and\n\t// keeps the invalid-hex warning from `hexToRgb` firing once per instance\n\t// instead of on every re-render.\n\tconst [derived] = useState(() => {\n\t\tconst clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));\n\t\treturn {\n\t\t\tclampedColorIntensity,\n\t\t\t// A click's extra brightness, capped so it never compounds\n\t\t\t// colorIntensity into the dither pass's clamp — see clickBoost().\n\t\t\tclickColorBoost: clickBoost(clampedColorIntensity),\n\t\t\tclampedHdrBoost: Math.max(1, Math.min(4, hdrBoost)),\n\t\t\tclampedDitherPixelSize: Math.max(1, ditherPixelSize),\n\t\t\tclampedDitherLevels: Math.max(2, Math.min(16, Math.floor(ditherLevels))),\n\t\t\tresolvedBackColor: (typeof backColor === \"string\"\n\t\t\t\t? hexToRgb(backColor)\n\t\t\t\t: backColor) as ColorRGB,\n\t\t};\n\t});\n\n\tconst canvasRef = useRef<HTMLCanvasElement>(null);\n\n\t// `prefers-reduced-motion: reduce` means no simulation at all: no engine,\n\t// so no rAF loop, no pointer listeners and no GPU objects. The layer stays\n\t// as an empty, pointer-transparent canvas. A live change of the preference\n\t// stops or starts the simulation without a remount.\n\tconst reducedMotion = useReducedMotion();\n\n\t// Singleton registry and monotonic mount counter, so an async (WebGPU)\n\t// startup that finishes out of order cannot let an older instance destroy\n\t// a newer one.\n\t//\n\t// Per-instance, matching the Svelte source: it declares both in the\n\t// instance `<script>` rather than `<script module>`, so each component owns\n\t// its own pair and no instance ever sees another's cleanup. `allowMultiple`\n\t// is therefore inert on both sides. The bug is ported deliberately\n\t// (PORTING.md: port the bug, note it) — fixing it here would silently make\n\t// a second `<FluidCursor />` destroy the first in React and not in Svelte.\n\tconst activeInstanceRef = useRef<(() => void) | null>(null);\n\tconst mountCounterRef = useRef(0);\n\n\t// Cached pre-scaled colors to avoid re-parsing hex on every splat.\n\tconst colorCacheRef = useRef<{\n\t\tcolorIndex: number;\n\t\tcachedFluidColorHex: string | undefined;\n\t\tcachedFluidColor: ColorRGB | null;\n\t\tcachedFluidColorsRef: string[] | undefined;\n\t\tcachedFluidColorsScaled: ColorRGB[];\n\t}>({\n\t\tcolorIndex: 0,\n\t\tcachedFluidColorHex: undefined,\n\t\tcachedFluidColor: null,\n\t\tcachedFluidColorsRef: undefined,\n\t\tcachedFluidColorsScaled: [],\n\t});\n\n\t// The props the running engine re-reads on every frame, mirroring the\n\t// Svelte source's reactive prop getters: `generateColor` reads the colors\n\t// on every splat, `notifyReady` re-checks `onReady`, and the WebGL pointer\n\t// mapping reads `contained`. Every other prop is captured when the mount\n\t// effect runs, exactly as `onMount`'s `config` object freezes it.\n\tconst fluidColorRef = useLiveRef(fluidColor);\n\tconst fluidColorsRef = useLiveRef(fluidColors);\n\tconst containedRef = useLiveRef(contained);\n\tconst onReadyRef = useLiveRef(onReady);\n\n\tfunction getScaledColor(hex: string): ColorRGB {\n\t\tconst { r, g, b } = hexToRgb(hex);\n\t\t// Frozen: these objects are cached and handed out by reference to every\n\t\t// pointer and every click, so an in-place `color.r *= …` downstream would\n\t\t// scale the palette itself — the freeze turns that into a TypeError at\n\t\t// the culprit instead of a fluid that brightens forever.\n\t\treturn Object.freeze({\n\t\t\tr: r * derived.clampedColorIntensity,\n\t\t\tg: g * derived.clampedColorIntensity,\n\t\t\tb: b * derived.clampedColorIntensity,\n\t\t});\n\t}\n\n\tfunction getCachedFluidColor(hex: string): ColorRGB {\n\t\tconst cache = colorCacheRef.current;\n\t\tif (cache.cachedFluidColor === null || cache.cachedFluidColorHex !== hex) {\n\t\t\tcache.cachedFluidColorHex = hex;\n\t\t\tcache.cachedFluidColor = getScaledColor(hex);\n\t\t}\n\t\treturn cache.cachedFluidColor;\n\t}\n\n\tfunction getCachedFluidColors(colors: string[]): ColorRGB[] {\n\t\tconst cache = colorCacheRef.current;\n\t\tif (cache.cachedFluidColorsRef !== colors) {\n\t\t\tcache.cachedFluidColorsRef = colors;\n\t\t\tcache.cachedFluidColorsScaled = colors.map((hex) => getScaledColor(hex));\n\t\t}\n\t\treturn cache.cachedFluidColorsScaled;\n\t}\n\n\tfunction generateColor(): ColorRGB {\n\t\tconst cache = colorCacheRef.current;\n\t\tif (fluidColorRef.current) {\n\t\t\treturn getCachedFluidColor(fluidColorRef.current);\n\t\t}\n\t\tconst palette = fluidColorsRef.current;\n\t\tif (palette && palette.length > 0) {\n\t\t\tconst colors = getCachedFluidColors(palette);\n\t\t\tconst idx = cache.colorIndex % colors.length;\n\t\t\tcache.colorIndex++;\n\t\t\treturn colors[idx]!;\n\t\t}\n\t\tconst c = HSVtoRGB(Math.random(), 1.0, 1.0);\n\t\tc.r *= derived.clampedColorIntensity;\n\t\tc.g *= derived.clampedColorIntensity;\n\t\tc.b *= derived.clampedColorIntensity;\n\t\treturn c;\n\t}\n\n\t// Singleton wiring shared by the WebGL and WebGPU paths.\n\tfunction registerInstance(cleanup: () => void): () => void {\n\t\tif (!allowMultiple) {\n\t\t\tif (activeInstanceRef.current) {\n\t\t\t\tif (isDev()) {\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t\"[FluidCursor] Destroying previous instance. Only one instance is allowed by default. Use `allowMultiple={true}` to opt out of singleton behavior.\"\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tactiveInstanceRef.current();\n\t\t\t}\n\t\t\tactiveInstanceRef.current = cleanup;\n\t\t\treturn () => {\n\t\t\t\tcleanup();\n\t\t\t\tif (activeInstanceRef.current === cleanup) {\n\t\t\t\t\tactiveInstanceRef.current = null;\n\t\t\t\t}\n\t\t\t};\n\t\t}\n\t\treturn cleanup;\n\t}\n\n\t// Inert handle for environments where no renderer came up at all, so a\n\t// consumer waiting on onReady can tell \"unsupported\" from \"still starting\".\n\tconst noRendererHandle: FluidCursorHandle = {\n\t\tmoveTo() {},\n\t\tpenUp() {},\n\t\tburst() {},\n\t\trenderLevel: \"none\",\n\t};\n\n\t// Hand the handle to the consumer. Guarded because the WebGPU path invokes\n\t// this inside its startup promise chain: an exception from the callback would\n\t// otherwise be caught as a WebGPU setup failure, retrying WebGL on a canvas\n\t// already claimed by WebGPU and dropping the registered cleanup. Re-thrown\n\t// asynchronously so it still surfaces as an unhandled error.\n\tfunction notifyReady(handle: FluidCursorHandle) {\n\t\tconst callback = onReadyRef.current;\n\t\tif (!callback) return;\n\t\ttry {\n\t\t\tcallback(handle);\n\t\t} catch (error) {\n\t\t\tqueueMicrotask(() => {\n\t\t\t\tthrow error;\n\t\t\t});\n\t\t}\n\t}\n\n\t// Mount-only, exactly like the Svelte `onMount`: every simulation prop is\n\t// captured here and a later change is ignored until the component is\n\t// re-keyed. The reduced-motion preference is the one dependency: it stops\n\t// the fluid (reduce on) or starts it afresh (reduce off). Re-running on\n\t// anything else would tear down the GL context and restart the fluid on\n\t// every unrelated re-render.\n\t//\n\t// The live media state is read as well: on a hydrated page the hook still\n\t// answers with its server value (`false`) during this first passive flush,\n\t// and only learns the real preference in its own effect, which schedules a\n\t// re-render. Without this read, reduce-on would start the engine once\n\t// (context, frame, listeners, onReady) just to tear it down again.\n\tuseEffect(() => {\n\t\tif (reducedMotion || prefersReducedMotionNow()) return;\n\t\tconst canvasNode = canvasRef.current;\n\t\tif (!canvasNode) return;\n\t\t// Re-bound with an explicit non-null type: `startWebGl` below is a\n\t\t// hoisted function declaration, and TypeScript does not carry an outer\n\t\t// null check into one.\n\t\tconst canvas: HTMLCanvasElement = canvasNode;\n\n\t\tconst mountToken = ++mountCounterRef.current;\n\t\tconst pointers = [pointerPrototype()];\n\n\t\tconst config = {\n\t\t\tSIM_RESOLUTION: simResolution,\n\t\t\tDYE_RESOLUTION: dyeResolution,\n\t\t\tCAPTURE_RESOLUTION: captureResolution,\n\t\t\tDENSITY_DISSIPATION: densityDissipation,\n\t\t\tVELOCITY_DISSIPATION: velocityDissipation,\n\t\t\tPRESSURE: pressure,\n\t\t\tPRESSURE_ITERATIONS: pressureIterations,\n\t\t\tCURL: curl,\n\t\t\tSPLAT_RADIUS: splatRadius,\n\t\t\tSPLAT_FORCE: splatForce,\n\t\t\tSHADING: shading,\n\t\t\tCOLOR_UPDATE_SPEED: colorUpdateSpeed,\n\t\t\tPAUSED: false,\n\t\t\tBACK_COLOR: derived.resolvedBackColor,\n\t\t\tTRANSPARENT: transparent,\n\t\t\tDITHER: dither,\n\t\t\tDITHER_PIXEL_SIZE: derived.clampedDitherPixelSize,\n\t\t\tDITHER_LEVELS: derived.clampedDitherLevels,\n\t\t};\n\n\t\t// HDR path: WebGPU engine (rgba16float backbuffer, display-p3,\n\t\t// extended tone mapping). Falls back to the WebGL path below when\n\t\t// WebGPU is unavailable or initialization fails.\n\t\tif (hdr && !dither && typeof navigator !== \"undefined\" && \"gpu\" in navigator) {\n\t\t\tlet disposed = false;\n\t\t\tlet activeCleanup: (() => void) | null = null;\n\t\t\tstartWebGpuFluid(canvas, {\n\t\t\t\tsimResolution: config.SIM_RESOLUTION,\n\t\t\t\tdyeResolution: config.DYE_RESOLUTION,\n\t\t\t\tdensityDissipation: config.DENSITY_DISSIPATION,\n\t\t\t\tvelocityDissipation: config.VELOCITY_DISSIPATION,\n\t\t\t\tpressure: config.PRESSURE,\n\t\t\t\tpressureIterations: config.PRESSURE_ITERATIONS,\n\t\t\t\tcurl: config.CURL,\n\t\t\t\tsplatRadius: config.SPLAT_RADIUS,\n\t\t\t\tsplatForce: config.SPLAT_FORCE,\n\t\t\t\tshading: config.SHADING,\n\t\t\t\tcolorUpdateSpeed: config.COLOR_UPDATE_SPEED,\n\t\t\t\texposure: derived.clampedHdrBoost,\n\t\t\t\tcontained,\n\t\t\t\tinteractive,\n\t\t\t\tautoSplat,\n\t\t\t\tautoSplatInterval,\n\t\t\t\tpauseWhenHidden,\n\t\t\t\tsplatOnMount,\n\t\t\t\tgenerateColor,\n\t\t\t\tclickBoost: derived.clickColorBoost,\n\t\t\t})\n\t\t\t\t.then((handle) => {\n\t\t\t\t\tif (disposed) {\n\t\t\t\t\t\thandle?.cleanup();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// A newer singleton instance mounted while we were starting up:\n\t\t\t\t\t// registering now would destroy it (newest-mount-wins would\n\t\t\t\t\t// invert). Discard this stale completion instead.\n\t\t\t\t\tif (!allowMultiple && mountToken !== mountCounterRef.current) {\n\t\t\t\t\t\thandle?.cleanup();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!handle) {\n\t\t\t\t\t\t// WebGPU unavailable: the WebGL fallback surfaces its own\n\t\t\t\t\t\t// handle through onReady.\n\t\t\t\t\t\tactiveCleanup = startWebGl() ?? null;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tactiveCleanup = registerInstance(handle.cleanup);\n\t\t\t\t\tnotifyReady(handle);\n\t\t\t\t})\n\t\t\t\t.catch((error: unknown) => {\n\t\t\t\t\t// Unexpected failure while wiring the WebGPU path: fall back\n\t\t\t\t\t// to WebGL instead of leaving a permanently blank canvas.\n\t\t\t\t\tif (isDev()) {\n\t\t\t\t\t\tconsole.warn(\"[FluidCursor] WebGPU setup failed, falling back to WebGL:\", error);\n\t\t\t\t\t}\n\t\t\t\t\tif (!disposed) activeCleanup = startWebGl() ?? null;\n\t\t\t\t});\n\t\t\treturn () => {\n\t\t\t\tdisposed = true;\n\t\t\t\tactiveCleanup?.();\n\t\t\t};\n\t\t}\n\n\t\treturn startWebGl();\n\n\t\t// WebGL engine path (default, and fallback when WebGPU is missing).\n\t\tfunction startWebGl(): (() => void) | undefined {\n\t\t\t// Every GL object the engine allocates registers its own deleter here so\n\t\t\t// `cleanup()` can hand the memory back. React runs the mount effect,\n\t\t\t// its cleanup and the effect again against the SAME <canvas> under\n\t\t\t// StrictMode and Fast Refresh, and `getContext` then returns the SAME\n\t\t\t// context: without these deletes the first engine's textures,\n\t\t\t// framebuffers, programs and shaders stay owned by a live context with\n\t\t\t// nothing left in JS referencing them, so the page holds two full\n\t\t\t// simulations' worth of GPU memory for its whole lifetime. The context\n\t\t\t// itself is deliberately NOT lost here (`WEBGL_lose_context`) — the\n\t\t\t// canvas is reused by the next mount, and a lost context cannot serve\n\t\t\t// it without an explicit restore.\n\t\t\tconst glDisposers: Array<() => void> = [];\n\t\t\t// Wide-gamut probe result, surfaced through the handle's renderLevel.\n\t\t\tlet renderLevel: FluidRenderLevel = \"webgl-sdr\";\n\t\t\t// Dedicated synthetic pointer driven programmatically through the\n\t\t\t// handle. It lives alongside the mouse pointer in the same list, so\n\t\t\t// applyInputs splats both and the two inputs coexist. Only created\n\t\t\t// when a consumer asked for the handle: an unused extra pointer would\n\t\t\t// still consume the shared fluidColors sequence in updateColors.\n\t\t\tconst autopilotPointer = onReady ? pointerPrototype() : null;\n\t\t\tif (autopilotPointer) pointers.push(autopilotPointer);\n\t\t\tlet penWasUp = true;\n\n\t\t\t// Get WebGL context\n\t\t\tconst context = getWebGLContext(canvas);\n\t\t\tif (!context.gl || !context.ext) {\n\t\t\t\t// No renderer at all (WebGPU already failed if it was tried).\n\t\t\t\tnotifyReady(noRendererHandle);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// These are guaranteed non-null after the check above\n\t\t\tconst gl = context.gl;\n\t\t\tconst ext = context.ext;\n\n\t\t\t// If no linear filtering, reduce resolution\n\t\t\tif (!ext.supportLinearFiltering) {\n\t\t\t\tconfig.DYE_RESOLUTION = 256;\n\t\t\t\tconfig.SHADING = false;\n\t\t\t}\n\n\t\t\tfunction getWebGLContext(canvas: HTMLCanvasElement) {\n\t\t\t\tconst params = {\n\t\t\t\t\talpha: true,\n\t\t\t\t\tdepth: false,\n\t\t\t\t\tstencil: false,\n\t\t\t\t\tantialias: false,\n\t\t\t\t\tpreserveDrawingBuffer: false,\n\t\t\t\t};\n\n\t\t\t\tlet gl = canvas.getContext(\"webgl2\", params) as WebGL2RenderingContext | null;\n\n\t\t\t\tif (!gl) {\n\t\t\t\t\tgl = (canvas.getContext(\"webgl\", params) ||\n\t\t\t\t\t\tcanvas.getContext(\"experimental-webgl\", params)) as WebGL2RenderingContext | null;\n\t\t\t\t}\n\n\t\t\t\tif (!gl) {\n\t\t\t\t\tconsole.error(\"Unable to initialize WebGL.\");\n\t\t\t\t\treturn { gl: null, ext: null };\n\t\t\t\t}\n\n\t\t\t\t// HDR fallback: wide-gamut backbuffer when the WebGPU engine is\n\t\t\t\t// unavailable. Colors are reinterpreted in P3 (more saturated).\n\t\t\t\t// The property is read back because assigning an unsupported color\n\t\t\t\t// space silently leaves the buffer in sRGB.\n\t\t\t\tif (hdr && \"drawingBufferColorSpace\" in gl) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tgl.drawingBufferColorSpace = \"display-p3\";\n\t\t\t\t\t\tif (gl.drawingBufferColorSpace === \"display-p3\") {\n\t\t\t\t\t\t\trenderLevel = \"webgl-p3\";\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Unsupported color space: buffer stays sRGB.\n\t\t\t\t\t}\n\t\t\t\t\tif (isDev()) {\n\t\t\t\t\t\tconsole.info(\n\t\t\t\t\t\t\trenderLevel === \"webgl-p3\"\n\t\t\t\t\t\t\t\t? \"[FluidCursor] HDR level: webgl-p3 (wide gamut fallback)\"\n\t\t\t\t\t\t\t\t: \"[FluidCursor] HDR level: webgl-sdr (display-p3 drawing buffer rejected)\"\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst isWebGL2 = \"drawBuffers\" in gl;\n\n\t\t\t\tlet supportLinearFiltering = false;\n\t\t\t\tlet halfFloat = null;\n\n\t\t\t\tif (isWebGL2) {\n\t\t\t\t\t(gl as WebGL2RenderingContext).getExtension(\"EXT_color_buffer_float\");\n\t\t\t\t\tsupportLinearFiltering = !!(gl as WebGL2RenderingContext).getExtension(\n\t\t\t\t\t\t\"OES_texture_float_linear\"\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\thalfFloat = gl.getExtension(\"OES_texture_half_float\");\n\t\t\t\t\tsupportLinearFiltering = !!gl.getExtension(\"OES_texture_half_float_linear\");\n\t\t\t\t}\n\n\t\t\t\tgl.clearColor(0, 0, 0, 1);\n\n\t\t\t\tconst halfFloatTexType = isWebGL2\n\t\t\t\t\t? (gl as WebGL2RenderingContext).HALF_FLOAT\n\t\t\t\t\t: (halfFloat && halfFloat.HALF_FLOAT_OES) || 0;\n\n\t\t\t\tlet formatRGBA: { internalFormat: number; format: number } | null;\n\t\t\t\tlet formatRG: { internalFormat: number; format: number } | null;\n\t\t\t\tlet formatR: { internalFormat: number; format: number } | null;\n\n\t\t\t\tif (isWebGL2) {\n\t\t\t\t\tformatRGBA = getSupportedFormat(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\t(gl as WebGL2RenderingContext).RGBA16F,\n\t\t\t\t\t\tgl.RGBA,\n\t\t\t\t\t\thalfFloatTexType\n\t\t\t\t\t);\n\t\t\t\t\tformatRG = getSupportedFormat(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\t(gl as WebGL2RenderingContext).RG16F,\n\t\t\t\t\t\t(gl as WebGL2RenderingContext).RG,\n\t\t\t\t\t\thalfFloatTexType\n\t\t\t\t\t);\n\t\t\t\t\tformatR = getSupportedFormat(\n\t\t\t\t\t\tgl,\n\t\t\t\t\t\t(gl as WebGL2RenderingContext).R16F,\n\t\t\t\t\t\t(gl as WebGL2RenderingContext).RED,\n\t\t\t\t\t\thalfFloatTexType\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tformatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType);\n\t\t\t\t\tformatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType);\n\t\t\t\t\tformatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType);\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tgl,\n\t\t\t\t\text: {\n\t\t\t\t\t\tformatRGBA,\n\t\t\t\t\t\tformatRG,\n\t\t\t\t\t\tformatR,\n\t\t\t\t\t\thalfFloatTexType,\n\t\t\t\t\t\tsupportLinearFiltering,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tfunction getSupportedFormat(\n\t\t\t\tgl: WebGLRenderingContext | WebGL2RenderingContext,\n\t\t\t\tinternalFormat: number,\n\t\t\t\tformat: number,\n\t\t\t\ttype: number\n\t\t\t): { internalFormat: number; format: number } | null {\n\t\t\t\tif (!supportRenderTextureFormat(gl, internalFormat, format, type)) {\n\t\t\t\t\tif (\"drawBuffers\" in gl) {\n\t\t\t\t\t\tconst gl2 = gl as WebGL2RenderingContext;\n\t\t\t\t\t\tswitch (internalFormat) {\n\t\t\t\t\t\t\tcase gl2.R16F:\n\t\t\t\t\t\t\t\treturn getSupportedFormat(gl2, gl2.RG16F, gl2.RG, type);\n\t\t\t\t\t\t\tcase gl2.RG16F:\n\t\t\t\t\t\t\t\treturn getSupportedFormat(gl2, gl2.RGBA16F, gl2.RGBA, type);\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t\treturn { internalFormat, format };\n\t\t\t}\n\n\t\t\tfunction supportRenderTextureFormat(\n\t\t\t\tgl: WebGLRenderingContext | WebGL2RenderingContext,\n\t\t\t\tinternalFormat: number,\n\t\t\t\tformat: number,\n\t\t\t\ttype: number\n\t\t\t) {\n\t\t\t\tconst texture = gl.createTexture();\n\t\t\t\tif (!texture) return false;\n\t\t\t\tglDisposers.push(() => gl.deleteTexture(texture));\n\n\t\t\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\t\t\t\tgl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null);\n\n\t\t\t\tconst fbo = gl.createFramebuffer();\n\t\t\t\tif (!fbo) return false;\n\t\t\t\tglDisposers.push(() => gl.deleteFramebuffer(fbo));\n\n\t\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n\t\t\t\tgl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n\t\t\t\tconst status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);\n\t\t\t\treturn status === gl.FRAMEBUFFER_COMPLETE;\n\t\t\t}\n\n\t\t\tfunction hashCode(s: string) {\n\t\t\t\tif (!s.length) return 0;\n\t\t\t\tlet hash = 0;\n\t\t\t\tfor (let i = 0; i < s.length; i++) {\n\t\t\t\t\thash = (hash << 5) - hash + s.charCodeAt(i);\n\t\t\t\t\thash |= 0;\n\t\t\t\t}\n\t\t\t\treturn hash;\n\t\t\t}\n\n\t\t\tfunction addKeywords(source: string, keywords: string[] | null) {\n\t\t\t\tif (!keywords) return source;\n\t\t\t\tlet keywordsString = \"\";\n\t\t\t\tfor (const keyword of keywords) {\n\t\t\t\t\tkeywordsString += `#define ${keyword}\\n`;\n\t\t\t\t}\n\t\t\t\treturn keywordsString + source;\n\t\t\t}\n\n\t\t\tfunction compileShader(\n\t\t\t\ttype: number,\n\t\t\t\tsource: string,\n\t\t\t\tkeywords: string[] | null = null\n\t\t\t): WebGLShader | null {\n\t\t\t\tconst shaderSource = addKeywords(source, keywords);\n\t\t\t\tconst shader = gl.createShader(type);\n\t\t\t\tif (!shader) return null;\n\t\t\t\tglDisposers.push(() => gl.deleteShader(shader));\n\t\t\t\tgl.shaderSource(shader, shaderSource);\n\t\t\t\tgl.compileShader(shader);\n\t\t\t\treturn shader;\n\t\t\t}\n\n\t\t\tfunction createProgram(\n\t\t\t\tvertexShader: WebGLShader | null,\n\t\t\t\tfragmentShader: WebGLShader | null\n\t\t\t): WebGLProgram | null {\n\t\t\t\tif (!vertexShader || !fragmentShader) return null;\n\t\t\t\tconst program = gl.createProgram();\n\t\t\t\tif (!program) return null;\n\t\t\t\tglDisposers.push(() => gl.deleteProgram(program));\n\t\t\t\tgl.attachShader(program, vertexShader);\n\t\t\t\tgl.attachShader(program, fragmentShader);\n\t\t\t\tgl.linkProgram(program);\n\t\t\t\treturn program;\n\t\t\t}\n\n\t\t\tfunction getUniforms(program: WebGLProgram) {\n\t\t\t\tconst uniforms: Record<string, WebGLUniformLocation | null> = {};\n\t\t\t\tconst uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);\n\t\t\t\tfor (let i = 0; i < uniformCount; i++) {\n\t\t\t\t\tconst uniformInfo = gl.getActiveUniform(program, i);\n\t\t\t\t\tif (uniformInfo) {\n\t\t\t\t\t\tuniforms[uniformInfo.name] = gl.getUniformLocation(program, uniformInfo.name);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn uniforms;\n\t\t\t}\n\n\t\t\tclass Program {\n\t\t\t\tprogram: WebGLProgram | null;\n\t\t\t\tuniforms: Record<string, WebGLUniformLocation | null>;\n\n\t\t\t\tconstructor(vertexShader: WebGLShader | null, fragmentShader: WebGLShader | null) {\n\t\t\t\t\tthis.program = createProgram(vertexShader, fragmentShader);\n\t\t\t\t\tthis.uniforms = this.program ? getUniforms(this.program) : {};\n\t\t\t\t}\n\n\t\t\t\tbind() {\n\t\t\t\t\tif (this.program) gl.useProgram(this.program);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tclass Material {\n\t\t\t\tvertexShader: WebGLShader | null;\n\t\t\t\tfragmentShaderSource: string;\n\t\t\t\tprograms: Record<number, WebGLProgram | null>;\n\t\t\t\tactiveProgram: WebGLProgram | null;\n\t\t\t\tuniforms: Record<string, WebGLUniformLocation | null>;\n\n\t\t\t\tconstructor(vertexShader: WebGLShader | null, fragmentShaderSource: string) {\n\t\t\t\t\tthis.vertexShader = vertexShader;\n\t\t\t\t\tthis.fragmentShaderSource = fragmentShaderSource;\n\t\t\t\t\tthis.programs = {};\n\t\t\t\t\tthis.activeProgram = null;\n\t\t\t\t\tthis.uniforms = {};\n\t\t\t\t}\n\n\t\t\t\tsetKeywords(keywords: string[]) {\n\t\t\t\t\tlet hash = 0;\n\t\t\t\t\tfor (const kw of keywords) {\n\t\t\t\t\t\thash += hashCode(kw);\n\t\t\t\t\t}\n\t\t\t\t\tlet program = this.programs[hash];\n\t\t\t\t\tif (program == null) {\n\t\t\t\t\t\tconst fragmentShader = compileShader(\n\t\t\t\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t\t\t\tthis.fragmentShaderSource,\n\t\t\t\t\t\t\tkeywords\n\t\t\t\t\t\t);\n\t\t\t\t\t\tprogram = createProgram(this.vertexShader, fragmentShader);\n\t\t\t\t\t\tthis.programs[hash] = program;\n\t\t\t\t\t}\n\t\t\t\t\tif (program === this.activeProgram) return;\n\t\t\t\t\tif (program) {\n\t\t\t\t\t\tthis.uniforms = getUniforms(program);\n\t\t\t\t\t}\n\t\t\t\t\tthis.activeProgram = program;\n\t\t\t\t}\n\n\t\t\t\tbind() {\n\t\t\t\t\tif (this.activeProgram) {\n\t\t\t\t\t\tgl.useProgram(this.activeProgram);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Shaders\n\t\t\tconst baseVertexShader = compileShader(\n\t\t\t\tgl.VERTEX_SHADER,\n\t\t\t\t`\n\t\t\tprecision highp float;\n\t\t\tattribute vec2 aPosition;\n\t\t\tvarying vec2 vUv;\n\t\t\tvarying vec2 vL;\n\t\t\tvarying vec2 vR;\n\t\t\tvarying vec2 vT;\n\t\t\tvarying vec2 vB;\n\t\t\tuniform vec2 texelSize;\n\n\t\t\tvoid main () {\n\t\t\t\tvUv = aPosition * 0.5 + 0.5;\n\t\t\t\tvL = vUv - vec2(texelSize.x, 0.0);\n\t\t\t\tvR = vUv + vec2(texelSize.x, 0.0);\n\t\t\t\tvT = vUv + vec2(0.0, texelSize.y);\n\t\t\t\tvB = vUv - vec2(0.0, texelSize.y);\n\t\t\t\tgl_Position = vec4(aPosition, 0.0, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst copyShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump sampler2D;\n\t\t\tvarying highp vec2 vUv;\n\t\t\tuniform sampler2D uTexture;\n\n\t\t\tvoid main () {\n\t\t\t\tgl_FragColor = texture2D(uTexture, vUv);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst clearShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump sampler2D;\n\t\t\tvarying highp vec2 vUv;\n\t\t\tuniform sampler2D uTexture;\n\t\t\tuniform float value;\n\n\t\t\tvoid main () {\n\t\t\t\tgl_FragColor = value * texture2D(uTexture, vUv);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst displayShaderSource = `\n\t\t\tprecision highp float;\n\t\t\tprecision highp sampler2D;\n\t\t\tvarying vec2 vUv;\n\t\t\tvarying vec2 vL;\n\t\t\tvarying vec2 vR;\n\t\t\tvarying vec2 vT;\n\t\t\tvarying vec2 vB;\n\t\t\tuniform sampler2D uTexture;\n\t\t\tuniform vec2 texelSize;\n\t\t\t#ifdef DITHERING\n\t\t\tuniform float uDitherPixel;\n\t\t\tuniform float uDitherLevels;\n\t\t\t#endif\n\n\t\t\tvec3 linearToGamma (vec3 color) {\n\t\t\t\tcolor = max(color, vec3(0));\n\t\t\t\treturn max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0));\n\t\t\t}\n\n\t\t\t#ifdef DITHERING\n\t\t\t// Recursive ordered-dither thresholds (4x4 Bayer, 16 levels).\n\t\t\t// GLSL ES 1.00-safe: floor/fract arithmetic only — no arrays,\n\t\t\t// no integer ops, no dynamic indexing.\n\t\t\tfloat bayer2 (vec2 a) {\n\t\t\t\ta = floor(a);\n\t\t\t\treturn fract(a.x * 0.5 + a.y * a.y * 0.75);\n\t\t\t}\n\n\t\t\tfloat bayer4 (vec2 a) {\n\t\t\t\treturn bayer2(a * 0.5) * 0.25 + bayer2(a);\n\t\t\t}\n\t\t\t#endif\n\n\t\t\tvoid main () {\n\t\t\t#ifdef DITHERING\n\t\t\t\t// One cell = one chunky pixel: every fragment in the cell samples\n\t\t\t\t// the dye at the cell center and shares one Bayer threshold, so\n\t\t\t\t// the cell renders as a solid square dot.\n\t\t\t\tvec2 cell = floor(gl_FragCoord.xy / uDitherPixel);\n\t\t\t\tvec2 uv = (cell + 0.5) * uDitherPixel * texelSize;\n\t\t\t\tvec3 c = clamp(texture2D(uTexture, uv).rgb, 0.0, 1.0);\n\n\t\t\t\tfloat steps = uDitherLevels - 1.0;\n\t\t\t\tc = floor(c * steps + bayer4(cell)) / steps;\n\n\t\t\t\t// Alpha from the quantized color: cells that quantize to black\n\t\t\t\t// stay fully transparent, so dot density encodes brightness\n\t\t\t\t// with no grey haze over the background.\n\t\t\t\tfloat a = max(c.r, max(c.g, c.b));\n\t\t\t\tgl_FragColor = vec4(c, a);\n\t\t\t#else\n\t\t\t\tvec3 c = texture2D(uTexture, vUv).rgb;\n\t\t\t\t#ifdef SHADING\n\t\t\t\t\tvec3 lc = texture2D(uTexture, vL).rgb;\n\t\t\t\t\tvec3 rc = texture2D(uTexture, vR).rgb;\n\t\t\t\t\tvec3 tc = texture2D(uTexture, vT).rgb;\n\t\t\t\t\tvec3 bc = texture2D(uTexture, vB).rgb;\n\n\t\t\t\t\tfloat dx = length(rc) - length(lc);\n\t\t\t\t\tfloat dy = length(tc) - length(bc);\n\n\t\t\t\t\tvec3 n = normalize(vec3(dx, dy, length(texelSize)));\n\t\t\t\t\tvec3 l = vec3(0.0, 0.0, 1.0);\n\n\t\t\t\t\tfloat diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);\n\t\t\t\t\tc *= diffuse;\n\t\t\t\t#endif\n\n\t\t\t\tfloat a = max(c.r, max(c.g, c.b));\n\t\t\t\tgl_FragColor = vec4(c, a);\n\t\t\t#endif\n\t\t\t}\n\t\t`;\n\n\t\t\tconst splatShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision highp float;\n\t\t\tprecision highp sampler2D;\n\t\t\tvarying vec2 vUv;\n\t\t\tuniform sampler2D uTarget;\n\t\t\tuniform float aspectRatio;\n\t\t\tuniform vec3 color;\n\t\t\tuniform vec2 point;\n\t\t\tuniform float radius;\n\t\t\tuniform float saturate;\n\n\t\t\tvoid main () {\n\t\t\t\tvec2 p = vUv - point.xy;\n\t\t\t\tp.x *= aspectRatio;\n\t\t\t\tvec3 splat = exp(-dot(p, p) / radius) * color;\n\t\t\t\tvec3 base = texture2D(uTarget, vUv).xyz;\n\t\t\t\t// A click splat lifts each texel to its own profile instead of adding\n\t\t\t\t// to it: what is already there never rises past what one click would\n\t\t\t\t// leave at that spot, so a burst of clicks paints the same disc once\n\t\t\t\t// instead of summing towards white and widening with every click.\n\t\t\t\tvec3 sum = saturate > 0.5 ? max(base, splat) : base + splat;\n\t\t\t\tgl_FragColor = vec4(sum, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst advectionShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision highp float;\n\t\t\tprecision highp sampler2D;\n\t\t\tvarying vec2 vUv;\n\t\t\tuniform sampler2D uVelocity;\n\t\t\tuniform sampler2D uSource;\n\t\t\tuniform vec2 texelSize;\n\t\t\tuniform vec2 dyeTexelSize;\n\t\t\tuniform float dt;\n\t\t\tuniform float dissipation;\n\n\t\t\tvec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {\n\t\t\t\tvec2 st = uv / tsize - 0.5;\n\t\t\t\tvec2 iuv = floor(st);\n\t\t\t\tvec2 fuv = fract(st);\n\n\t\t\t\tvec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);\n\t\t\t\tvec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);\n\t\t\t\tvec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);\n\t\t\t\tvec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);\n\n\t\t\t\treturn mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);\n\t\t\t}\n\n\t\t\tvoid main () {\n\t\t\t\t#ifdef MANUAL_FILTERING\n\t\t\t\t\tvec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;\n\t\t\t\t\tvec4 result = bilerp(uSource, coord, dyeTexelSize);\n\t\t\t\t#else\n\t\t\t\t\tvec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;\n\t\t\t\t\tvec4 result = texture2D(uSource, coord);\n\t\t\t\t#endif\n\t\t\t\tfloat decay = 1.0 + dissipation * dt;\n\t\t\t\tgl_FragColor = result / decay;\n\t\t\t}\n\t\t`,\n\t\t\t\text.supportLinearFiltering ? null : [\"MANUAL_FILTERING\"]\n\t\t\t);\n\n\t\t\tconst divergenceShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump sampler2D;\n\t\t\tvarying highp vec2 vUv;\n\t\t\tvarying highp vec2 vL;\n\t\t\tvarying highp vec2 vR;\n\t\t\tvarying highp vec2 vT;\n\t\t\tvarying highp vec2 vB;\n\t\t\tuniform sampler2D uVelocity;\n\n\t\t\tvoid main () {\n\t\t\t\tfloat L = texture2D(uVelocity, vL).x;\n\t\t\t\tfloat R = texture2D(uVelocity, vR).x;\n\t\t\t\tfloat T = texture2D(uVelocity, vT).y;\n\t\t\t\tfloat B = texture2D(uVelocity, vB).y;\n\n\t\t\t\tvec2 C = texture2D(uVelocity, vUv).xy;\n\t\t\t\tif (vL.x < 0.0) { L = -C.x; }\n\t\t\t\tif (vR.x > 1.0) { R = -C.x; }\n\t\t\t\tif (vT.y > 1.0) { T = -C.y; }\n\t\t\t\tif (vB.y < 0.0) { B = -C.y; }\n\n\t\t\t\tfloat div = 0.5 * (R - L + T - B);\n\t\t\t\tgl_FragColor = vec4(div, 0.0, 0.0, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst curlShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump sampler2D;\n\t\t\tvarying highp vec2 vUv;\n\t\t\tvarying highp vec2 vL;\n\t\t\tvarying highp vec2 vR;\n\t\t\tvarying highp vec2 vT;\n\t\t\tvarying highp vec2 vB;\n\t\t\tuniform sampler2D uVelocity;\n\n\t\t\tvoid main () {\n\t\t\t\tfloat L = texture2D(uVelocity, vL).y;\n\t\t\t\tfloat R = texture2D(uVelocity, vR).y;\n\t\t\t\tfloat T = texture2D(uVelocity, vT).x;\n\t\t\t\tfloat B = texture2D(uVelocity, vB).x;\n\t\t\t\tfloat vorticity = R - L - T + B;\n\t\t\t\tgl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst vorticityShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision highp float;\n\t\t\tprecision highp sampler2D;\n\t\t\tvarying vec2 vUv;\n\t\t\tvarying vec2 vL;\n\t\t\tvarying vec2 vR;\n\t\t\tvarying vec2 vT;\n\t\t\tvarying vec2 vB;\n\t\t\tuniform sampler2D uVelocity;\n\t\t\tuniform sampler2D uCurl;\n\t\t\tuniform float curl;\n\t\t\tuniform float dt;\n\n\t\t\tvoid main () {\n\t\t\t\tfloat L = texture2D(uCurl, vL).x;\n\t\t\t\tfloat R = texture2D(uCurl, vR).x;\n\t\t\t\tfloat T = texture2D(uCurl, vT).x;\n\t\t\t\tfloat B = texture2D(uCurl, vB).x;\n\t\t\t\tfloat C = texture2D(uCurl, vUv).x;\n\n\t\t\t\tvec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));\n\t\t\t\tforce /= length(force) + 0.0001;\n\t\t\t\tforce *= curl * C;\n\t\t\t\tforce.y *= -1.0;\n\n\t\t\t\tvec2 velocity = texture2D(uVelocity, vUv).xy;\n\t\t\t\tvelocity += force * dt;\n\t\t\t\tvelocity = min(max(velocity, -1000.0), 1000.0);\n\t\t\t\tgl_FragColor = vec4(velocity, 0.0, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst pressureShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump sampler2D;\n\t\t\tvarying highp vec2 vUv;\n\t\t\tvarying highp vec2 vL;\n\t\t\tvarying highp vec2 vR;\n\t\t\tvarying highp vec2 vT;\n\t\t\tvarying highp vec2 vB;\n\t\t\tuniform sampler2D uPressure;\n\t\t\tuniform sampler2D uDivergence;\n\n\t\t\tvoid main () {\n\t\t\t\tfloat L = texture2D(uPressure, vL).x;\n\t\t\t\tfloat R = texture2D(uPressure, vR).x;\n\t\t\t\tfloat T = texture2D(uPressure, vT).x;\n\t\t\t\tfloat B = texture2D(uPressure, vB).x;\n\t\t\t\tfloat C = texture2D(uPressure, vUv).x;\n\t\t\t\tfloat divergence = texture2D(uDivergence, vUv).x;\n\t\t\t\tfloat pressure = (L + R + B + T - divergence) * 0.25;\n\t\t\t\tgl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\tconst gradientSubtractShader = compileShader(\n\t\t\t\tgl.FRAGMENT_SHADER,\n\t\t\t\t`\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump sampler2D;\n\t\t\tvarying highp vec2 vUv;\n\t\t\tvarying highp vec2 vL;\n\t\t\tvarying highp vec2 vR;\n\t\t\tvarying highp vec2 vT;\n\t\t\tvarying highp vec2 vB;\n\t\t\tuniform sampler2D uPressure;\n\t\t\tuniform sampler2D uVelocity;\n\n\t\t\tvoid main () {\n\t\t\t\tfloat L = texture2D(uPressure, vL).x;\n\t\t\t\tfloat R = texture2D(uPressure, vR).x;\n\t\t\t\tfloat T = texture2D(uPressure, vT).x;\n\t\t\t\tfloat B = texture2D(uPressure, vB).x;\n\t\t\t\tvec2 velocity = texture2D(uVelocity, vUv).xy;\n\t\t\t\tvelocity.xy -= vec2(R - L, T - B);\n\t\t\t\tgl_FragColor = vec4(velocity, 0.0, 1.0);\n\t\t\t}\n\t\t`\n\t\t\t);\n\n\t\t\t// Fullscreen Triangles\n\t\t\tinterface FBO {\n\t\t\t\ttexture: WebGLTexture;\n\t\t\t\tfbo: WebGLFramebuffer;\n\t\t\t\twidth: number;\n\t\t\t\theight: number;\n\t\t\t\ttexelSizeX: number;\n\t\t\t\ttexelSizeY: number;\n\t\t\t\tattach: (id: number) => number;\n\t\t\t}\n\n\t\t\tinterface DoubleFBO {\n\t\t\t\twidth: number;\n\t\t\t\theight: number;\n\t\t\t\ttexelSizeX: number;\n\t\t\t\ttexelSizeY: number;\n\t\t\t\tread: FBO;\n\t\t\t\twrite: FBO;\n\t\t\t\tswap: () => void;\n\t\t\t}\n\n\t\t\tconst blit = (() => {\n\t\t\t\tconst buffer = gl.createBuffer()!;\n\t\t\t\tglDisposers.push(() => gl.deleteBuffer(buffer));\n\t\t\t\tgl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n\t\t\t\tgl.bufferData(\n\t\t\t\t\tgl.ARRAY_BUFFER,\n\t\t\t\t\tnew Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]),\n\t\t\t\t\tgl.STATIC_DRAW\n\t\t\t\t);\n\t\t\t\tconst elemBuffer = gl.createBuffer()!;\n\t\t\t\tglDisposers.push(() => gl.deleteBuffer(elemBuffer));\n\t\t\t\tgl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elemBuffer);\n\t\t\t\tgl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);\n\t\t\t\tgl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n\t\t\t\tgl.enableVertexAttribArray(0);\n\n\t\t\t\treturn (target: FBO | null, doClear = false) => {\n\t\t\t\t\tif (!gl) return;\n\t\t\t\t\tif (!target) {\n\t\t\t\t\t\tgl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);\n\t\t\t\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, null);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tgl.viewport(0, 0, target.width, target.height);\n\t\t\t\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);\n\t\t\t\t\t}\n\t\t\t\t\tif (doClear) {\n\t\t\t\t\t\tgl.clearColor(0, 0, 0, 1);\n\t\t\t\t\t\tgl.clear(gl.COLOR_BUFFER_BIT);\n\t\t\t\t\t}\n\t\t\t\t\tgl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);\n\t\t\t\t};\n\t\t\t})();\n\n\t\t\t// FBO variables\n\t\t\tlet dye: DoubleFBO;\n\t\t\tlet velocity: DoubleFBO;\n\t\t\tlet divergenceFBO: FBO;\n\t\t\tlet curlFBO: FBO;\n\t\t\tlet pressureFBO: DoubleFBO;\n\n\t\t\t// WebGL Programs\n\t\t\tconst copyProgram = new Program(baseVertexShader, copyShader);\n\t\t\tconst clearProgram = new Program(baseVertexShader, clearShader);\n\t\t\tconst splatProgram = new Program(baseVertexShader, splatShader);\n\t\t\tconst advectionProgram = new Program(baseVertexShader, advectionShader);\n\t\t\tconst divergenceProgram = new Program(baseVertexShader, divergenceShader);\n\t\t\tconst curlProgram = new Program(baseVertexShader, curlShader);\n\t\t\tconst vorticityProgram = new Program(baseVertexShader, vorticityShader);\n\t\t\tconst pressureProgram = new Program(baseVertexShader, pressureShader);\n\t\t\tconst gradienSubtractProgram = new Program(baseVertexShader, gradientSubtractShader);\n\t\t\tconst displayMaterial = new Material(baseVertexShader, displayShaderSource);\n\n\t\t\t// FBO creation\n\t\t\tfunction createFBO(\n\t\t\t\tw: number,\n\t\t\t\th: number,\n\t\t\t\tinternalFormat: number,\n\t\t\t\tformat: number,\n\t\t\t\ttype: number,\n\t\t\t\tparam: number\n\t\t\t): FBO {\n\t\t\t\tgl.activeTexture(gl.TEXTURE0);\n\t\t\t\tconst texture = gl.createTexture()!;\n\t\t\t\tglDisposers.push(() => gl.deleteTexture(texture));\n\t\t\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n\t\t\t\tgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n\t\t\t\tgl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null);\n\t\t\t\tconst fbo = gl.createFramebuffer()!;\n\t\t\t\tglDisposers.push(() => gl.deleteFramebuffer(fbo));\n\t\t\t\tgl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n\t\t\t\tgl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);\n\t\t\t\tgl.viewport(0, 0, w, h);\n\t\t\t\tgl.clear(gl.COLOR_BUFFER_BIT);\n\n\t\t\t\tconst texelSizeX = 1 / w;\n\t\t\t\tconst texelSizeY = 1 / h;\n\n\t\t\t\treturn {\n\t\t\t\t\ttexture,\n\t\t\t\t\tfbo,\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: h,\n\t\t\t\t\ttexelSizeX,\n\t\t\t\t\ttexelSizeY,\n\t\t\t\t\tattach(id: number) {\n\t\t\t\t\t\tgl.activeTexture(gl.TEXTURE0 + id);\n\t\t\t\t\t\tgl.bindTexture(gl.TEXTURE_2D, texture);\n\t\t\t\t\t\treturn id;\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tfunction createDoubleFBO(\n\t\t\t\tw: number,\n\t\t\t\th: number,\n\t\t\t\tinternalFormat: number,\n\t\t\t\tformat: number,\n\t\t\t\ttype: number,\n\t\t\t\tparam: number\n\t\t\t): DoubleFBO {\n\t\t\t\tconst fbo1 = createFBO(w, h, internalFormat, format, type, param);\n\t\t\t\tconst fbo2 = createFBO(w, h, internalFormat, format, type, param);\n\t\t\t\treturn {\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: h,\n\t\t\t\t\ttexelSizeX: fbo1.texelSizeX,\n\t\t\t\t\ttexelSizeY: fbo1.texelSizeY,\n\t\t\t\t\tread: fbo1,\n\t\t\t\t\twrite: fbo2,\n\t\t\t\t\tswap() {\n\t\t\t\t\t\tconst tmp = this.read;\n\t\t\t\t\t\tthis.read = this.write;\n\t\t\t\t\t\tthis.write = tmp;\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tfunction resizeFBO(\n\t\t\t\ttarget: FBO,\n\t\t\t\tw: number,\n\t\t\t\th: number,\n\t\t\t\tinternalFormat: number,\n\t\t\t\tformat: number,\n\t\t\t\ttype: number,\n\t\t\t\tparam: number\n\t\t\t) {\n\t\t\t\tconst newFBO = createFBO(w, h, internalFormat, format, type, param);\n\t\t\t\tcopyProgram.bind();\n\t\t\t\tif (copyProgram.uniforms.uTexture)\n\t\t\t\t\tgl.uniform1i(copyProgram.uniforms.uTexture, target.attach(0));\n\t\t\t\tblit(newFBO, false);\n\t\t\t\treturn newFBO;\n\t\t\t}\n\n\t\t\tfunction resizeDoubleFBO(\n\t\t\t\ttarget: DoubleFBO,\n\t\t\t\tw: number,\n\t\t\t\th: number,\n\t\t\t\tinternalFormat: number,\n\t\t\t\tformat: number,\n\t\t\t\ttype: number,\n\t\t\t\tparam: number\n\t\t\t) {\n\t\t\t\tif (target.width === w && target.height === h) return target;\n\t\t\t\ttarget.read = resizeFBO(target.read, w, h, internalFormat, format, type, param);\n\t\t\t\ttarget.write = createFBO(w, h, internalFormat, format, type, param);\n\t\t\t\ttarget.width = w;\n\t\t\t\ttarget.height = h;\n\t\t\t\ttarget.texelSizeX = 1 / w;\n\t\t\t\ttarget.texelSizeY = 1 / h;\n\t\t\t\treturn target;\n\t\t\t}\n\n\t\t\tfunction initFramebuffers() {\n\t\t\t\tconst simRes = getResolution(config.SIM_RESOLUTION);\n\t\t\t\tconst dyeRes = getResolution(config.DYE_RESOLUTION);\n\n\t\t\t\tconst texType = ext.halfFloatTexType;\n\t\t\t\tconst rgba = ext.formatRGBA!;\n\t\t\t\tconst rg = ext.formatRG!;\n\t\t\t\tconst r = ext.formatR!;\n\t\t\t\tconst filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;\n\t\t\t\tgl.disable(gl.BLEND);\n\n\t\t\t\tif (!dye) {\n\t\t\t\t\tdye = createDoubleFBO(\n\t\t\t\t\t\tdyeRes.width,\n\t\t\t\t\t\tdyeRes.height,\n\t\t\t\t\t\trgba.internalFormat,\n\t\t\t\t\t\trgba.format,\n\t\t\t\t\t\ttexType,\n\t\t\t\t\t\tfiltering\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tdye = resizeDoubleFBO(\n\t\t\t\t\t\tdye,\n\t\t\t\t\t\tdyeRes.width,\n\t\t\t\t\t\tdyeRes.height,\n\t\t\t\t\t\trgba.internalFormat,\n\t\t\t\t\t\trgba.format,\n\t\t\t\t\t\ttexType,\n\t\t\t\t\t\tfiltering\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tif (!velocity) {\n\t\t\t\t\tvelocity = createDoubleFBO(\n\t\t\t\t\t\tsimRes.width,\n\t\t\t\t\t\tsimRes.height,\n\t\t\t\t\t\trg.internalFormat,\n\t\t\t\t\t\trg.format,\n\t\t\t\t\t\ttexType,\n\t\t\t\t\t\tfiltering\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tvelocity = resizeDoubleFBO(\n\t\t\t\t\t\tvelocity,\n\t\t\t\t\t\tsimRes.width,\n\t\t\t\t\t\tsimRes.height,\n\t\t\t\t\t\trg.internalFormat,\n\t\t\t\t\t\trg.format,\n\t\t\t\t\t\ttexType,\n\t\t\t\t\t\tfiltering\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tdivergenceFBO = createFBO(\n\t\t\t\t\tsimRes.width,\n\t\t\t\t\tsimRes.height,\n\t\t\t\t\tr.internalFormat,\n\t\t\t\t\tr.format,\n\t\t\t\t\ttexType,\n\t\t\t\t\tgl.NEAREST\n\t\t\t\t);\n\t\t\t\tcurlFBO = createFBO(\n\t\t\t\t\tsimRes.width,\n\t\t\t\t\tsimRes.height,\n\t\t\t\t\tr.internalFormat,\n\t\t\t\t\tr.format,\n\t\t\t\t\ttexType,\n\t\t\t\t\tgl.NEAREST\n\t\t\t\t);\n\t\t\t\tpressureFBO = createDoubleFBO(\n\t\t\t\t\tsimRes.width,\n\t\t\t\t\tsimRes.height,\n\t\t\t\t\tr.internalFormat,\n\t\t\t\t\tr.format,\n\t\t\t\t\ttexType,\n\t\t\t\t\tgl.NEAREST\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tfunction updateKeywords() {\n\t\t\t\tconst displayKeywords: string[] = [];\n\t\t\t\tif (config.SHADING) displayKeywords.push(\"SHADING\");\n\t\t\t\tif (config.DITHER) displayKeywords.push(\"DITHERING\");\n\t\t\t\tdisplayMaterial.setKeywords(displayKeywords);\n\t\t\t}\n\n\t\t\tfunction getResolution(resolution: number) {\n\t\t\t\tconst w = gl.drawingBufferWidth;\n\t\t\t\tconst h = gl.drawingBufferHeight;\n\t\t\t\tconst aspectRatio = w / h;\n\t\t\t\tconst aspect = aspectRatio < 1 ? 1 / aspectRatio : aspectRatio;\n\t\t\t\tconst min = Math.round(resolution);\n\t\t\t\tconst max = Math.round(resolution * aspect);\n\t\t\t\tif (w > h) {\n\t\t\t\t\treturn { width: max, height: min };\n\t\t\t\t}\n\t\t\t\treturn { width: min, height: max };\n\t\t\t}\n\n\t\t\tfunction scaleByPixelRatio(input: number) {\n\t\t\t\tconst pixelRatio = window.devicePixelRatio || 1;\n\t\t\t\treturn Math.floor(input * pixelRatio);\n\t\t\t}\n\n\t\t\tlet canvasRectCache: DOMRect | null = null;\n\t\t\tfunction updateCanvasRectCache() {\n\t\t\t\tif (containedRef.current && canvas) canvasRectCache = canvas.getBoundingClientRect();\n\t\t\t}\n\t\t\tif (contained) {\n\t\t\t\twindow.addEventListener(\"resize\", updateCanvasRectCache, { passive: true });\n\t\t\t\twindow.addEventListener(\"scroll\", updateCanvasRectCache, { passive: true });\n\t\t\t\tupdateCanvasRectCache();\n\t\t\t}\n\n\t\t\tfunction getCanvasPos(clientX: number, clientY: number): { x: number; y: number } {\n\t\t\t\tif (containedRef.current) {\n\t\t\t\t\tif (!canvasRectCache) updateCanvasRectCache();\n\t\t\t\t\tconst rect = canvasRectCache!;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tx: scaleByPixelRatio(clientX - rect.left),\n\t\t\t\t\t\ty: scaleByPixelRatio(clientY - rect.top),\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\treturn { x: scaleByPixelRatio(clientX), y: scaleByPixelRatio(clientY) };\n\t\t\t}\n\n\t\t\t// Simulation Setup\n\t\t\tupdateKeywords();\n\t\t\tinitFramebuffers();\n\n\t\t\tlet lastUpdateTime = Date.now();\n\t\t\tlet colorUpdateTimer = 0.0;\n\t\t\tlet animationFrameId: number;\n\n\t\t\tlet isVisible = true;\n\n\t\t\tfunction updateFrame() {\n\t\t\t\tif (!isVisible) return;\n\t\t\t\tconst dt = calcDeltaTime();\n\t\t\t\tif (resizeCanvas()) initFramebuffers();\n\t\t\t\tupdateColors(dt);\n\t\t\t\tapplyInputs();\n\t\t\t\tstep(dt);\n\t\t\t\trender(null);\n\t\t\t\tanimationFrameId = requestAnimationFrame(updateFrame);\n\t\t\t}\n\n\t\t\tfunction calcDeltaTime() {\n\t\t\t\tconst now = Date.now();\n\t\t\t\tlet dt = (now - lastUpdateTime) / 1000;\n\t\t\t\tdt = Math.min(dt, 0.016666);\n\t\t\t\tlastUpdateTime = now;\n\t\t\t\treturn dt;\n\t\t\t}\n\n\t\t\tfunction resizeCanvas() {\n\t\t\t\tconst width = scaleByPixelRatio(canvas.clientWidth);\n\t\t\t\tconst height = scaleByPixelRatio(canvas.clientHeight);\n\t\t\t\tif (canvas.width !== width || canvas.height !== height) {\n\t\t\t\t\tcanvas.width = width;\n\t\t\t\t\tcanvas.height = height;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tfunction updateColors(dt: number) {\n\t\t\t\tcolorUpdateTimer += dt * config.COLOR_UPDATE_SPEED;\n\t\t\t\tif (colorUpdateTimer >= 1) {\n\t\t\t\t\tcolorUpdateTimer = wrap(colorUpdateTimer, 0, 1);\n\t\t\t\t\tpointers.forEach((p) => {\n\t\t\t\t\t\t// The synthetic pointer's color belongs to the handle: pulling\n\t\t\t\t\t\t// from the generator here would advance the shared fluidColors\n\t\t\t\t\t\t// index twice per update and overwrite explicit stroke colors.\n\t\t\t\t\t\tif (p === autopilotPointer) return;\n\t\t\t\t\t\tp.color = generateColor();\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfunction applyInputs() {\n\t\t\t\tfor (const p of pointers) {\n\t\t\t\t\tif (p.moved) {\n\t\t\t\t\t\tp.moved = false;\n\t\t\t\t\t\tsplatPointer(p);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfunction step(dt: number) {\n\t\t\t\tgl.disable(gl.BLEND);\n\n\t\t\t\t// Curl\n\t\t\t\tcurlProgram.bind();\n\t\t\t\tif (curlProgram.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(curlProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);\n\t\t\t\t}\n\t\t\t\tif (curlProgram.uniforms.uVelocity) {\n\t\t\t\t\tgl.uniform1i(curlProgram.uniforms.uVelocity, velocity.read.attach(0));\n\t\t\t\t}\n\t\t\t\tblit(curlFBO);\n\n\t\t\t\t// Vorticity\n\t\t\t\tvorticityProgram.bind();\n\t\t\t\tif (vorticityProgram.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(\n\t\t\t\t\t\tvorticityProgram.uniforms.texelSize,\n\t\t\t\t\t\tvelocity.texelSizeX,\n\t\t\t\t\t\tvelocity.texelSizeY\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (vorticityProgram.uniforms.uVelocity) {\n\t\t\t\t\tgl.uniform1i(vorticityProgram.uniforms.uVelocity, velocity.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (vorticityProgram.uniforms.uCurl) {\n\t\t\t\t\tgl.uniform1i(vorticityProgram.uniforms.uCurl, curlFBO.attach(1));\n\t\t\t\t}\n\t\t\t\tif (vorticityProgram.uniforms.curl) {\n\t\t\t\t\tgl.uniform1f(vorticityProgram.uniforms.curl, config.CURL);\n\t\t\t\t}\n\t\t\t\tif (vorticityProgram.uniforms.dt) {\n\t\t\t\t\tgl.uniform1f(vorticityProgram.uniforms.dt, dt);\n\t\t\t\t}\n\t\t\t\tblit(velocity.write);\n\t\t\t\tvelocity.swap();\n\n\t\t\t\t// Divergence\n\t\t\t\tdivergenceProgram.bind();\n\t\t\t\tif (divergenceProgram.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(\n\t\t\t\t\t\tdivergenceProgram.uniforms.texelSize,\n\t\t\t\t\t\tvelocity.texelSizeX,\n\t\t\t\t\t\tvelocity.texelSizeY\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (divergenceProgram.uniforms.uVelocity) {\n\t\t\t\t\tgl.uniform1i(divergenceProgram.uniforms.uVelocity, velocity.read.attach(0));\n\t\t\t\t}\n\t\t\t\tblit(divergenceFBO);\n\n\t\t\t\t// Clear pressure\n\t\t\t\tclearProgram.bind();\n\t\t\t\tif (clearProgram.uniforms.uTexture) {\n\t\t\t\t\tgl.uniform1i(clearProgram.uniforms.uTexture, pressureFBO.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (clearProgram.uniforms.value) {\n\t\t\t\t\tgl.uniform1f(clearProgram.uniforms.value, config.PRESSURE);\n\t\t\t\t}\n\t\t\t\tblit(pressureFBO.write);\n\t\t\t\tpressureFBO.swap();\n\n\t\t\t\t// Pressure\n\t\t\t\tpressureProgram.bind();\n\t\t\t\tif (pressureProgram.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(\n\t\t\t\t\t\tpressureProgram.uniforms.texelSize,\n\t\t\t\t\t\tvelocity.texelSizeX,\n\t\t\t\t\t\tvelocity.texelSizeY\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (pressureProgram.uniforms.uDivergence) {\n\t\t\t\t\tgl.uniform1i(pressureProgram.uniforms.uDivergence, divergenceFBO.attach(0));\n\t\t\t\t}\n\t\t\t\tfor (let i = 0; i < config.PRESSURE_ITERATIONS; i++) {\n\t\t\t\t\tif (pressureProgram.uniforms.uPressure) {\n\t\t\t\t\t\tgl.uniform1i(pressureProgram.uniforms.uPressure, pressureFBO.read.attach(1));\n\t\t\t\t\t}\n\t\t\t\t\tblit(pressureFBO.write);\n\t\t\t\t\tpressureFBO.swap();\n\t\t\t\t}\n\n\t\t\t\t// Gradient Subtract\n\t\t\t\tgradienSubtractProgram.bind();\n\t\t\t\tif (gradienSubtractProgram.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(\n\t\t\t\t\t\tgradienSubtractProgram.uniforms.texelSize,\n\t\t\t\t\t\tvelocity.texelSizeX,\n\t\t\t\t\t\tvelocity.texelSizeY\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (gradienSubtractProgram.uniforms.uPressure) {\n\t\t\t\t\tgl.uniform1i(gradienSubtractProgram.uniforms.uPressure, pressureFBO.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (gradienSubtractProgram.uniforms.uVelocity) {\n\t\t\t\t\tgl.uniform1i(gradienSubtractProgram.uniforms.uVelocity, velocity.read.attach(1));\n\t\t\t\t}\n\t\t\t\tblit(velocity.write);\n\t\t\t\tvelocity.swap();\n\n\t\t\t\t// Advection - velocity\n\t\t\t\tadvectionProgram.bind();\n\t\t\t\tif (advectionProgram.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(\n\t\t\t\t\t\tadvectionProgram.uniforms.texelSize,\n\t\t\t\t\t\tvelocity.texelSizeX,\n\t\t\t\t\t\tvelocity.texelSizeY\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (!ext.supportLinearFiltering && advectionProgram.uniforms.dyeTexelSize) {\n\t\t\t\t\tgl.uniform2f(\n\t\t\t\t\t\tadvectionProgram.uniforms.dyeTexelSize,\n\t\t\t\t\t\tvelocity.texelSizeX,\n\t\t\t\t\t\tvelocity.texelSizeY\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tconst velocityId = velocity.read.attach(0);\n\t\t\t\tif (advectionProgram.uniforms.uVelocity) {\n\t\t\t\t\tgl.uniform1i(advectionProgram.uniforms.uVelocity, velocityId);\n\t\t\t\t}\n\t\t\t\tif (advectionProgram.uniforms.uSource) {\n\t\t\t\t\tgl.uniform1i(advectionProgram.uniforms.uSource, velocityId);\n\t\t\t\t}\n\t\t\t\tif (advectionProgram.uniforms.dt) {\n\t\t\t\t\tgl.uniform1f(advectionProgram.uniforms.dt, dt);\n\t\t\t\t}\n\t\t\t\tif (advectionProgram.uniforms.dissipation) {\n\t\t\t\t\tgl.uniform1f(advectionProgram.uniforms.dissipation, config.VELOCITY_DISSIPATION);\n\t\t\t\t}\n\t\t\t\tblit(velocity.write);\n\t\t\t\tvelocity.swap();\n\n\t\t\t\t// Advection - dye\n\t\t\t\tif (!ext.supportLinearFiltering && advectionProgram.uniforms.dyeTexelSize) {\n\t\t\t\t\tgl.uniform2f(advectionProgram.uniforms.dyeTexelSize, dye.texelSizeX, dye.texelSizeY);\n\t\t\t\t}\n\t\t\t\tif (advectionProgram.uniforms.uVelocity) {\n\t\t\t\t\tgl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (advectionProgram.uniforms.uSource) {\n\t\t\t\t\tgl.uniform1i(advectionProgram.uniforms.uSource, dye.read.attach(1));\n\t\t\t\t}\n\t\t\t\tif (advectionProgram.uniforms.dissipation) {\n\t\t\t\t\tgl.uniform1f(advectionProgram.uniforms.dissipation, config.DENSITY_DISSIPATION);\n\t\t\t\t}\n\t\t\t\tblit(dye.write);\n\t\t\t\tdye.swap();\n\t\t\t}\n\n\t\t\tfunction render(target: FBO | null) {\n\t\t\t\tgl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n\t\t\t\tgl.enable(gl.BLEND);\n\t\t\t\tdrawDisplay(target);\n\t\t\t}\n\n\t\t\tfunction drawDisplay(target: FBO | null) {\n\t\t\t\tconst width = target ? target.width : gl.drawingBufferWidth;\n\t\t\t\tconst height = target ? target.height : gl.drawingBufferHeight;\n\t\t\t\tdisplayMaterial.bind();\n\t\t\t\tif ((config.SHADING || config.DITHER) && displayMaterial.uniforms.texelSize) {\n\t\t\t\t\tgl.uniform2f(displayMaterial.uniforms.texelSize, 1 / width, 1 / height);\n\t\t\t\t}\n\t\t\t\tif (displayMaterial.uniforms.uTexture) {\n\t\t\t\t\tgl.uniform1i(displayMaterial.uniforms.uTexture, dye.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (config.DITHER) {\n\t\t\t\t\tif (displayMaterial.uniforms.uDitherPixel) {\n\t\t\t\t\t\tgl.uniform1f(\n\t\t\t\t\t\t\tdisplayMaterial.uniforms.uDitherPixel,\n\t\t\t\t\t\t\tconfig.DITHER_PIXEL_SIZE * (window.devicePixelRatio || 1)\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tif (displayMaterial.uniforms.uDitherLevels) {\n\t\t\t\t\t\tgl.uniform1f(displayMaterial.uniforms.uDitherLevels, config.DITHER_LEVELS);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tblit(target, false);\n\t\t\t}\n\n\t\t\t// Interaction\n\t\t\tfunction splatPointer(pointer: Pointer) {\n\t\t\t\tconst dx = pointer.deltaX * config.SPLAT_FORCE;\n\t\t\t\tconst dy = pointer.deltaY * config.SPLAT_FORCE;\n\t\t\t\tsplat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);\n\t\t\t}\n\n\t\t\tfunction clickSplat(pointer: Pointer) {\n\t\t\t\t// generateColor() is already intensity-scaled; the boost is capped so\n\t\t\t\t// the peak stays at CLICK_PEAK rather than compounding colorIntensity.\n\t\t\t\t// COPY, never scale in place: with `fluidColor` / `fluidColors` set,\n\t\t\t\t// generateColor() hands back the cached palette object itself, and\n\t\t\t\t// scaling that multiplied the palette again on every click — the\n\t\t\t\t// 10th click painted with a colour 10^10 times brighter — which is\n\t\t\t\t// what turned a few clicks into a giant disc and a few more into\n\t\t\t\t// NaN and nothing at all.\n\t\t\t\tconst base = generateColor();\n\t\t\t\tconst color = {\n\t\t\t\t\tr: base.r * derived.clickColorBoost,\n\t\t\t\t\tg: base.g * derived.clickColorBoost,\n\t\t\t\t\tb: base.b * derived.clickColorBoost,\n\t\t\t\t};\n\t\t\t\tconst dx = 10 * (Math.random() - 0.5);\n\t\t\t\tconst dy = 30 * (Math.random() - 0.5);\n\t\t\t\t// Saturating: a burst of clicks on one spot pools at this colour\n\t\t\t\t// rather than summing — a click has next to no velocity to carry\n\t\t\t\t// the dye away, so additive stacking used to turn into a white disc.\n\t\t\t\tsplat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * Inject velocity and dye at a point. `saturate` is the click mode: the\n\t\t\t * dye pass then caps each texel at the splat colour instead of adding\n\t\t\t * to it (velocity is always additive — a click's push is negligible).\n\t\t\t */\n\t\t\tfunction splat(\n\t\t\t\tx: number,\n\t\t\t\ty: number,\n\t\t\t\tdx: number,\n\t\t\t\tdy: number,\n\t\t\t\tcolor: ColorRGB,\n\t\t\t\tsaturate = false\n\t\t\t) {\n\t\t\t\tsplatProgram.bind();\n\t\t\t\tif (splatProgram.uniforms.uTarget) {\n\t\t\t\t\tgl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.aspectRatio) {\n\t\t\t\t\tgl.uniform1f(splatProgram.uniforms.aspectRatio, canvas.width / canvas.height);\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.point) {\n\t\t\t\t\tgl.uniform2f(splatProgram.uniforms.point, x, y);\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.color) {\n\t\t\t\t\tgl.uniform3f(splatProgram.uniforms.color, dx, dy, 0);\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.saturate) {\n\t\t\t\t\tgl.uniform1f(splatProgram.uniforms.saturate, 0);\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.radius) {\n\t\t\t\t\tlet radius = correctRadius(config.SPLAT_RADIUS / 100);\n\t\t\t\t\tif (containedRef.current) {\n\t\t\t\t\t\tradius = scaleRadiusForContainer(radius, canvas.clientHeight, window.innerHeight);\n\t\t\t\t\t}\n\t\t\t\t\tgl.uniform1f(splatProgram.uniforms.radius, radius);\n\t\t\t\t}\n\t\t\t\tblit(velocity.write);\n\t\t\t\tvelocity.swap();\n\n\t\t\t\tif (splatProgram.uniforms.uTarget) {\n\t\t\t\t\tgl.uniform1i(splatProgram.uniforms.uTarget, dye.read.attach(0));\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.color) {\n\t\t\t\t\tgl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b);\n\t\t\t\t}\n\t\t\t\tif (splatProgram.uniforms.saturate) {\n\t\t\t\t\tgl.uniform1f(splatProgram.uniforms.saturate, saturate ? 1 : 0);\n\t\t\t\t}\n\t\t\t\tblit(dye.write);\n\t\t\t\tdye.swap();\n\t\t\t}\n\n\t\t\t// Frame id of the `multipleSplats` arc chain, cancelled by `cleanup()`:\n\t\t\t// the chain re-schedules itself for up to ~15 frames and would keep\n\t\t\t// issuing GL work after the component is gone.\n\t\t\tlet splatFrameId = 0;\n\n\t\t\tfunction multipleSplats(steps: number) {\n\t\t\t\t// Simulate a cursor sweep along a random arc — each step on its own frame\n\t\t\t\t// so the velocity field has time to evolve between injections.\n\t\t\t\tconst angle = Math.random() * Math.PI * 2;\n\t\t\t\tconst cx = 0.25 + Math.random() * 0.5;\n\t\t\t\tconst cy = 0.25 + Math.random() * 0.5;\n\t\t\t\tconst arcSpan = Math.PI * (0.8 + Math.random() * 0.8);\n\t\t\t\tlet i = 0;\n\n\t\t\t\tfunction step() {\n\t\t\t\t\tif (i >= steps) return;\n\t\t\t\t\tconst t = i / Math.max(1, steps - 1);\n\t\t\t\t\tconst a = angle + t * arcSpan;\n\t\t\t\t\tconst r = 0.15 + 0.05 * Math.sin(t * Math.PI);\n\t\t\t\t\tconst x = cx + Math.cos(a) * r;\n\t\t\t\t\tconst y = cy + Math.sin(a) * r;\n\t\t\t\t\tconst color = generateColor();\n\t\t\t\t\tsplat(\n\t\t\t\t\t\tx,\n\t\t\t\t\t\ty,\n\t\t\t\t\t\t-Math.sin(a) * config.SPLAT_FORCE * 0.5,\n\t\t\t\t\t\tMath.cos(a) * config.SPLAT_FORCE * 0.5,\n\t\t\t\t\t\tcolor\n\t\t\t\t\t);\n\t\t\t\t\ti++;\n\t\t\t\t\tif (i < steps) splatFrameId = requestAnimationFrame(step);\n\t\t\t\t}\n\t\t\t\tsplatFrameId = requestAnimationFrame(step);\n\t\t\t}\n\n\t\t\tfunction correctRadius(radius: number) {\n\t\t\t\tconst aspectRatio = canvas.width / canvas.height;\n\t\t\t\tif (aspectRatio > 1) radius *= aspectRatio;\n\t\t\t\treturn radius;\n\t\t\t}\n\n\t\t\tfunction updatePointerDownData(pointer: Pointer, id: number, posX: number, posY: number) {\n\t\t\t\tpointer.id = id;\n\t\t\t\tpointer.down = true;\n\t\t\t\tpointer.moved = false;\n\t\t\t\tpointer.texcoordX = posX / canvas.width;\n\t\t\t\tpointer.texcoordY = 1 - posY / canvas.height;\n\t\t\t\tpointer.prevTexcoordX = pointer.texcoordX;\n\t\t\t\tpointer.prevTexcoordY = pointer.texcoordY;\n\t\t\t\tpointer.deltaX = 0;\n\t\t\t\tpointer.deltaY = 0;\n\t\t\t\tpointer.color = generateColor();\n\t\t\t}\n\n\t\t\tfunction updatePointerMoveData(\n\t\t\t\tpointer: Pointer,\n\t\t\t\tposX: number,\n\t\t\t\tposY: number,\n\t\t\t\tcolor: ColorRGB\n\t\t\t) {\n\t\t\t\tpointer.prevTexcoordX = pointer.texcoordX;\n\t\t\t\tpointer.prevTexcoordY = pointer.texcoordY;\n\t\t\t\tpointer.texcoordX = posX / canvas.width;\n\t\t\t\tpointer.texcoordY = 1 - posY / canvas.height;\n\t\t\t\tpointer.deltaX = correctDeltaX(pointer.texcoordX - pointer.prevTexcoordX);\n\t\t\t\tpointer.deltaY = correctDeltaY(pointer.texcoordY - pointer.prevTexcoordY);\n\t\t\t\tpointer.moved = Math.abs(pointer.deltaX) > 0 || Math.abs(pointer.deltaY) > 0;\n\t\t\t\tpointer.color = color;\n\t\t\t}\n\n\t\t\tfunction correctDeltaX(delta: number) {\n\t\t\t\tconst aspectRatio = canvas.width / canvas.height;\n\t\t\t\tif (aspectRatio < 1) delta *= aspectRatio;\n\t\t\t\treturn delta;\n\t\t\t}\n\n\t\t\tfunction correctDeltaY(delta: number) {\n\t\t\t\tconst aspectRatio = canvas.width / canvas.height;\n\t\t\t\tif (aspectRatio > 1) delta /= aspectRatio;\n\t\t\t\treturn delta;\n\t\t\t}\n\n\t\t\t// Programmatic drive surface (exposed through onReady).\n\t\t\t//\n\t\t\t// x,y in [0,1], top-left origin. `updatePointerMoveData` applies\n\t\t\t// `texcoordY = 1 - posY / height`, so feeding it a top-left pixel Y\n\t\t\t// yields the same path a real mouse event's clientY takes. This is the\n\t\t\t// only Y flip on the WebGL path.\n\t\t\tfunction autopilotMoveTo(x: number, y: number, color?: ColorRGB) {\n\t\t\t\tif (!autopilotPointer) return;\n\t\t\t\tconst posX = x * canvas.width;\n\t\t\t\tconst posY = y * canvas.height;\n\t\t\t\tif (penWasUp) {\n\t\t\t\t\t// First move of a new stroke: reposition only (mirrors\n\t\t\t\t\t// updatePointerDownData) so the jump from the previous stroke's\n\t\t\t\t\t// end never draws a connecting streak.\n\t\t\t\t\tpenWasUp = false;\n\t\t\t\t\tautopilotPointer.down = true;\n\t\t\t\t\tautopilotPointer.moved = false;\n\t\t\t\t\tautopilotPointer.texcoordX = posX / canvas.width;\n\t\t\t\t\tautopilotPointer.texcoordY = 1 - posY / canvas.height;\n\t\t\t\t\tautopilotPointer.prevTexcoordX = autopilotPointer.texcoordX;\n\t\t\t\t\tautopilotPointer.prevTexcoordY = autopilotPointer.texcoordY;\n\t\t\t\t\tautopilotPointer.deltaX = 0;\n\t\t\t\t\tautopilotPointer.deltaY = 0;\n\t\t\t\t\t// A stroke with no explicit color takes a fresh generated one, as\n\t\t\t\t\t// a real pointer-down does: the prototype's black would inject\n\t\t\t\t\t// invisible dye.\n\t\t\t\t\tautopilotPointer.color = color ?? generateColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tupdatePointerMoveData(autopilotPointer, posX, posY, color ?? autopilotPointer.color);\n\t\t\t}\n\t\t\tfunction autopilotPenUp() {\n\t\t\t\t// `moved` is deliberately left alone: applyInputs consumes and clears\n\t\t\t\t// it on the next frame, so clearing it here would swallow the final\n\t\t\t\t// segment of a stroke finished synchronously before that frame.\n\t\t\t\tpenWasUp = true;\n\t\t\t\tif (autopilotPointer) autopilotPointer.down = false;\n\t\t\t}\n\t\t\t// Top-left origin in, bottom-up texcoords out: flip y and negate dy.\n\t\t\tfunction autopilotBurst(x: number, y: number, dx: number, dy: number, color: ColorRGB) {\n\t\t\t\tsplat(x, 1 - y, dx, -dy, color);\n\t\t\t}\n\n\t\t\t// Event Listeners\n\t\t\tfunction handleMouseDown(e: MouseEvent) {\n\t\t\t\tconst pointer = pointers[0]!;\n\t\t\t\tconst { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);\n\t\t\t\tupdatePointerDownData(pointer, -1, posX, posY);\n\t\t\t\tclickSplat(pointer);\n\t\t\t}\n\n\t\t\t// The release half of mousedown / touchstart. `down` used to latch true\n\t\t\t// for the life of the engine — nothing on this path reads it, but the\n\t\t\t// pointer is part of the handle's contract and should tell the truth.\n\t\t\tfunction handlePointerUp() {\n\t\t\t\tpointers[0]!.down = false;\n\t\t\t}\n\n\t\t\tfunction handleFirstMouseMove(e: MouseEvent) {\n\t\t\t\tconst pointer = pointers[0]!;\n\t\t\t\tconst { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);\n\t\t\t\tconst color = generateColor();\n\t\t\t\tupdatePointerMoveData(pointer, posX, posY, color);\n\t\t\t\tdocument.body.removeEventListener(\"mousemove\", handleFirstMouseMove);\n\t\t\t}\n\n\t\t\tfunction handleMouseMove(e: MouseEvent) {\n\t\t\t\tconst pointer = pointers[0]!;\n\t\t\t\tconst { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);\n\t\t\t\tupdatePointerMoveData(pointer, posX, posY, pointer.color);\n\t\t\t}\n\n\t\t\tfunction handleFirstTouchStart(e: TouchEvent) {\n\t\t\t\tconst touches = e.targetTouches;\n\t\t\t\tconst pointer = pointers[0]!;\n\t\t\t\tfor (let i = 0; i < touches.length; i++) {\n\t\t\t\t\tconst touch = touches[i]!;\n\t\t\t\t\tconst { x: posX, y: posY } = getCanvasPos(touch.clientX, touch.clientY);\n\t\t\t\t\tupdatePointerDownData(pointer, touch.identifier, posX, posY);\n\t\t\t\t}\n\t\t\t\tdocument.body.removeEventListener(\"touchstart\", handleFirstTouchStart);\n\t\t\t}\n\n\t\t\tfunction handleTouchStart(e: TouchEvent) {\n\t\t\t\tconst touches = e.targetTouches;\n\t\t\t\tconst pointer = pointers[0]!;\n\t\t\t\tfor (let i = 0; i < touches.length; i++) {\n\t\t\t\t\tconst touch = touches[i]!;\n\t\t\t\t\tconst { x: posX, y: posY } = getCanvasPos(touch.clientX, touch.clientY);\n\t\t\t\t\tupdatePointerDownData(pointer, touch.identifier, posX, posY);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfunction handleTouchMove(e: TouchEvent) {\n\t\t\t\tconst touches = e.targetTouches;\n\t\t\t\tconst pointer = pointers[0]!;\n\t\t\t\tfor (let i = 0; i < touches.length; i++) {\n\t\t\t\t\tconst touch = touches[i]!;\n\t\t\t\t\tconst { x: posX, y: posY } = getCanvasPos(touch.clientX, touch.clientY);\n\t\t\t\t\tupdatePointerMoveData(pointer, posX, posY, pointer.color);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add event listeners\n\t\t\tif (interactive) {\n\t\t\t\twindow.addEventListener(\"mousedown\", handleMouseDown);\n\t\t\t\twindow.addEventListener(\"mouseup\", handlePointerUp);\n\t\t\t\tdocument.body.addEventListener(\"mousemove\", handleFirstMouseMove);\n\t\t\t\twindow.addEventListener(\"mousemove\", handleMouseMove);\n\t\t\t\tdocument.body.addEventListener(\"touchstart\", handleFirstTouchStart, { passive: true });\n\t\t\t\twindow.addEventListener(\"touchstart\", handleTouchStart, { passive: true });\n\t\t\t\twindow.addEventListener(\"touchmove\", handleTouchMove, { passive: true });\n\t\t\t\twindow.addEventListener(\"touchend\", handlePointerUp);\n\t\t\t\twindow.addEventListener(\"touchcancel\", handlePointerUp);\n\t\t\t}\n\n\t\t\t// Pause animation when scrolled out of view\n\t\t\tlet observer: IntersectionObserver | null = null;\n\t\t\tif (pauseWhenHidden) {\n\t\t\t\tobserver = new IntersectionObserver(\n\t\t\t\t\t([entry]) => {\n\t\t\t\t\t\tconst wasVisible = isVisible;\n\t\t\t\t\t\tisVisible = entry!.isIntersecting;\n\t\t\t\t\t\tif (isVisible && !wasVisible) {\n\t\t\t\t\t\t\tlastUpdateTime = Date.now();\n\t\t\t\t\t\t\tanimationFrameId = requestAnimationFrame(updateFrame);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{ threshold: 0 }\n\t\t\t\t);\n\t\t\t\tobserver.observe(canvas);\n\t\t\t}\n\n\t\t\t// Start animation\n\t\t\tupdateFrame();\n\t\t\tif (splatOnMount) multipleSplats(Math.floor(Math.random() * 6) + 10);\n\n\t\t\t// Auto-splat timer\n\t\t\tlet autoSplatTimer: ReturnType<typeof setInterval> | null = null;\n\t\t\tif (autoSplat) {\n\t\t\t\tautoSplatTimer = setInterval(() => {\n\t\t\t\t\tconst color = generateColor();\n\t\t\t\t\tsplat(\n\t\t\t\t\t\tMath.random(),\n\t\t\t\t\t\tMath.random(),\n\t\t\t\t\t\t(Math.random() - 0.5) * 200,\n\t\t\t\t\t\t(Math.random() - 0.5) * 200,\n\t\t\t\t\t\tcolor\n\t\t\t\t\t);\n\t\t\t\t}, autoSplatInterval);\n\t\t\t}\n\n\t\t\t// Cleanup\n\t\t\tfunction cleanup() {\n\t\t\t\tcancelAnimationFrame(animationFrameId);\n\t\t\t\tcancelAnimationFrame(splatFrameId);\n\t\t\t\tif (observer) observer.disconnect();\n\t\t\t\tif (autoSplatTimer) clearInterval(autoSplatTimer);\n\t\t\t\tif (interactive) {\n\t\t\t\t\twindow.removeEventListener(\"mousedown\", handleMouseDown);\n\t\t\t\t\twindow.removeEventListener(\"mouseup\", handlePointerUp);\n\t\t\t\t\tdocument.body.removeEventListener(\"mousemove\", handleFirstMouseMove);\n\t\t\t\t\twindow.removeEventListener(\"mousemove\", handleMouseMove);\n\t\t\t\t\tdocument.body.removeEventListener(\"touchstart\", handleFirstTouchStart);\n\t\t\t\t\twindow.removeEventListener(\"touchstart\", handleTouchStart);\n\t\t\t\t\twindow.removeEventListener(\"touchmove\", handleTouchMove);\n\t\t\t\t\twindow.removeEventListener(\"touchend\", handlePointerUp);\n\t\t\t\t\twindow.removeEventListener(\"touchcancel\", handlePointerUp);\n\t\t\t\t}\n\t\t\t\tif (contained) {\n\t\t\t\t\twindow.removeEventListener(\"resize\", updateCanvasRectCache);\n\t\t\t\t\twindow.removeEventListener(\"scroll\", updateCanvasRectCache);\n\t\t\t\t}\n\t\t\t\tfor (const dispose of glDisposers) dispose();\n\t\t\t\tglDisposers.length = 0;\n\t\t\t}\n\n\t\t\t// Register before notifying: a throwing callback must not cost us the\n\t\t\t// cleanup registration (listeners, rAF loop and GL resources would\n\t\t\t// otherwise outlive the component).\n\t\t\tconst dispose = registerInstance(cleanup);\n\t\t\tnotifyReady({\n\t\t\t\tmoveTo: autopilotMoveTo,\n\t\t\t\tpenUp: autopilotPenUp,\n\t\t\t\tburst: autopilotBurst,\n\t\t\t\trenderLevel,\n\t\t\t});\n\n\t\t\treturn dispose;\n\t\t}\n\t}, [reducedMotion]);\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\tcontained\n\t\t\t\t\t? \"pointer-events-none absolute inset-0 h-full w-full\"\n\t\t\t\t\t: \"pointer-events-none fixed top-0 left-0 z-50 size-full\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t>\n\t\t\t{/* Keyed on the preference: a stop leaves a blank canvas rather than\n\t\t\t    the last drawn frame, and a restart gets a fresh element. */}\n\t\t\t<canvas\n\t\t\t\tkey={reducedMotion ? \"static\" : \"live\"}\n\t\t\t\tref={canvasRef}\n\t\t\t\tclassName={contained ? \"block h-full w-full\" : \"block h-screen w-screen\"}\n\t\t\t/>\n\t\t</div>\n\t);\n}\n\n/**\n * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.\n */\nexport const FluidCursorAdvanced = FluidCursor;\n\n/**\n * @deprecated Use `FluidCursorProps` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.\n */\nexport interface FluidCursorAdvancedProps extends FluidCursorProps {}\n","type":"registry:ui","target":"@ui/fancy/components/fluid-cursor/FluidCursor.tsx"},{"path":"react/src/components/fluid-cursor/dev.ts","content":"\"use client\";\n\n// Dev-only diagnostics gate.\n//\n// `process.env.NODE_ENV` is the one flag every React toolchain already\n// replaces statically, so it is read as that exact literal, inside try/catch:\n// where nothing rewrites it and no `process` exists the read throws, is caught,\n// and the gate reads false, the safe default for a published package. No\n// `typeof process` guard may sit in front: in a browser bundle it would\n// short-circuit before the rewritten literal is reached. `process` is declared\n// locally so no Node typings are needed.\n//\n// Read lazily, inside a function: nothing here may run at module scope, so the\n// value can never differ between a server render and its hydration.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nexport function isDev(): boolean {\n\ttry {\n\t\treturn process.env.NODE_ENV !== \"production\";\n\t} catch {\n\t\treturn false;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/fluid-cursor/dev.ts"},{"path":"react/src/components/fluid-cursor/fluid-shared.ts","content":"\"use client\";\n\n// Pure helpers shared by the WebGL and WebGPU fluid engines.\n\nexport interface ColorRGB {\n\tr: number;\n\tg: number;\n\tb: number;\n}\n\nexport interface Pointer {\n\tid: number;\n\ttexcoordX: number;\n\ttexcoordY: number;\n\tprevTexcoordX: number;\n\tprevTexcoordY: number;\n\tdeltaX: number;\n\tdeltaY: number;\n\tdown: boolean;\n\tmoved: boolean;\n\tcolor: ColorRGB;\n}\n\nexport function pointerPrototype(): Pointer {\n\treturn {\n\t\tid: -1,\n\t\ttexcoordX: 0,\n\t\ttexcoordY: 0,\n\t\tprevTexcoordX: 0,\n\t\tprevTexcoordY: 0,\n\t\tdeltaX: 0,\n\t\tdeltaY: 0,\n\t\tdown: false,\n\t\tmoved: false,\n\t\tcolor: { r: 0, g: 0, b: 0 },\n\t};\n}\n\nexport function hexToRgb(hex: string): ColorRGB {\n\tconst result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n\tif (!result) {\n\t\tconsole.warn(\n\t\t\t`[FluidCursor] Invalid hex color: \"${hex}\". Expected format: \"#rrggbb\". Falling back to white.`\n\t\t);\n\t\treturn { r: 1, g: 1, b: 1 };\n\t}\n\t// The three groups are mandatory in the pattern above, so a match always\n\t// carries them.\n\treturn {\n\t\tr: parseInt(result[1]!, 16) / 255,\n\t\tg: parseInt(result[2]!, 16) / 255,\n\t\tb: parseInt(result[3]!, 16) / 255,\n\t};\n}\n\nexport function HSVtoRGB(h: number, s: number, v: number): ColorRGB {\n\tlet r = 0;\n\tlet g = 0;\n\tlet b = 0;\n\tconst i = Math.floor(h * 6);\n\tconst f = h * 6 - i;\n\tconst p = v * (1 - s);\n\tconst q = v * (1 - f * s);\n\tconst t = v * (1 - (1 - f) * s);\n\n\tswitch (i % 6) {\n\t\tcase 0:\n\t\t\tr = v;\n\t\t\tg = t;\n\t\t\tb = p;\n\t\t\tbreak;\n\t\tcase 1:\n\t\t\tr = q;\n\t\t\tg = v;\n\t\t\tb = p;\n\t\t\tbreak;\n\t\tcase 2:\n\t\t\tr = p;\n\t\t\tg = v;\n\t\t\tb = t;\n\t\t\tbreak;\n\t\tcase 3:\n\t\t\tr = p;\n\t\t\tg = q;\n\t\t\tb = v;\n\t\t\tbreak;\n\t\tcase 4:\n\t\t\tr = t;\n\t\t\tg = p;\n\t\t\tb = v;\n\t\t\tbreak;\n\t\tcase 5:\n\t\t\tr = v;\n\t\t\tg = p;\n\t\t\tb = q;\n\t\t\tbreak;\n\t}\n\treturn { r, g, b };\n}\n\nexport function wrap(value: number, min: number, max: number) {\n\tconst range = max - min;\n\tif (range === 0) return min;\n\treturn ((value - min) % range) + min;\n}\n\n/** Sim/dye grid size for a given base resolution and drawing buffer dimensions. */\nexport function getSimResolution(resolution: number, bufferWidth: number, bufferHeight: number) {\n\tconst aspectRatio = bufferWidth / bufferHeight;\n\tconst aspect = aspectRatio < 1 ? 1 / aspectRatio : aspectRatio;\n\tconst min = Math.round(resolution);\n\tconst max = Math.round(resolution * aspect);\n\tif (bufferWidth > bufferHeight) {\n\t\treturn { width: max, height: min };\n\t}\n\treturn { width: min, height: max };\n}\n\nexport function correctRadius(radius: number, width: number, height: number) {\n\tconst aspectRatio = width / height;\n\tif (aspectRatio > 1) return radius * aspectRatio;\n\treturn radius;\n}\n\n/**\n * In contained mode the splat radius is a fraction of the canvas, so the same\n * settings that look right fullscreen produce a tiny effect inside a small\n * demo container. Grow the radius with the viewport/container ratio —\n * sub-linearly in apparent size (the gaussian's characteristic length is\n * sqrt(radius), so `radius * k` scales the length by sqrt(k)): a small demo\n * reads bigger without mimicking fullscreen 1:1. Must be applied to the\n * final, aspect-corrected radius; the 0.09 cap keeps the characteristic\n * length under ~30% of the container height.\n */\nexport function scaleRadiusForContainer(\n\tradius: number,\n\tcontainerHeight: number,\n\tviewportHeight: number\n): number {\n\tif (containerHeight <= 0 || viewportHeight <= 0) return radius;\n\tconst k = viewportHeight / containerHeight;\n\tif (k <= 1) return radius;\n\t// The cap only limits the auto-scaling — a caller-provided radius that is\n\t// already larger must never be shrunk by entering a small container.\n\treturn Math.max(radius, Math.min(radius * k, 0.09));\n}\n\n/**\n * Dye a click deposits at its centre, in multiples of the hue: the engine's\n * default `colorIntensity` (0.15) times the ×10 boost the solver has always\n * given a click — the amount that reads as a bright pop on every renderer.\n */\nexport const CLICK_PEAK = 1.5;\n\n/**\n * The boost a click applies to an already intensity-scaled colour, chosen so\n * the peak lands on CLICK_PEAK whatever `colorIntensity` is.\n *\n * The historical fixed ×10 compounded `colorIntensity`. At the default 0.15\n * that is a 1.5 peak; but the dither pass asks consumers to raise the\n * intensity to ≈0.4+, and at 0.6 the same ×10 stacked 5.4 per click into an\n * unclamped half-float dye buffer that the dither display then clamps to 1\n * and quantises — every click grew a flat, saturated disc that never read as\n * a splat. Capping the boost at 10 keeps intensities ≤ 0.15 byte-identical.\n */\nexport function clickBoost(colorIntensity: number): number {\n\tif (!(colorIntensity > 0)) return 0;\n\treturn Math.min(10, CLICK_PEAK / colorIntensity);\n}\n\nexport function correctDeltaX(delta: number, width: number, height: number) {\n\tconst aspectRatio = width / height;\n\tif (aspectRatio < 1) return delta * aspectRatio;\n\treturn delta;\n}\n\nexport function correctDeltaY(delta: number, width: number, height: number) {\n\tconst aspectRatio = width / height;\n\tif (aspectRatio > 1) return delta / aspectRatio;\n\treturn delta;\n}\n\n/**\n * How the fluid is actually being rendered, surfaced as data so callers can\n * react (e.g. tell true HDR output apart from a clamped fallback):\n * - \"webgpu-hdr\": WebGPU with extended tone mapping active AND a display\n *   reporting `(dynamic-range: high)` — true HDR output.\n * - \"webgpu-sdr\": WebGPU float16 + P3, but the browser clamped tone mapping or\n *   the output display is SDR.\n * - \"webgl-p3\":   WebGL fallback with a display-p3 drawing buffer.\n * - \"webgl-sdr\":  WebGL fallback, plain sRGB.\n * - \"none\":       no GPU rendering available at all; the handle is inert.\n *\n * Sampled once when the handle is created: it does not update if the window\n * later moves to a display with a different dynamic range.\n */\nexport type FluidRenderLevel = \"webgpu-hdr\" | \"webgpu-sdr\" | \"webgl-p3\" | \"webgl-sdr\" | \"none\";\n\n/**\n * Imperative control surface handed to `FluidCursor`'s `onReady` callback so a\n * parent can drive the simulation programmatically (trace a path, fire a\n * one-off burst) and read back which render path actually engaged. Coordinates\n * are in [0,1] with the ORIGIN AT THE TOP-LEFT (CSS convention); velocity is\n * derived from consecutive `moveTo` calls, so smooth paths feel like a real\n * cursor. Any engine-specific Y flip is internal.\n */\nexport interface FluidCursorHandle {\n\t/** Drive the synthetic pointer like a mouse. The next `moveTo` after `penUp` repositions without drawing. */\n\tmoveTo(x: number, y: number, color?: ColorRGB): void;\n\t/** Lift the pen so the next `moveTo` repositions without producing a connecting streak. */\n\tpenUp(): void;\n\t/** One-off impulse (click-like burst). Same coord convention; dx/dy in engine velocity units. */\n\tburst(x: number, y: number, dx: number, dy: number, color: ColorRGB): void;\n\treadonly renderLevel: FluidRenderLevel;\n}\n","type":"registry:ui","target":"@ui/fancy/components/fluid-cursor/fluid-shared.ts"},{"path":"react/src/components/fluid-cursor/index.ts","content":"\"use client\";\n\nexport { FluidCursor } from \"./FluidCursor\";\nexport type { FluidCursorProps } from \"./FluidCursor\";\n\n/**\n * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.\n */\nexport { FluidCursorAdvanced } from \"./FluidCursor\";\nexport type { FluidCursorAdvancedProps } from \"./FluidCursor\";\n\nexport type { FluidCursorHandle, FluidRenderLevel } from \"./fluid-shared\";\n","type":"registry:ui","target":"@ui/fancy/components/fluid-cursor/index.ts"},{"path":"react/src/components/fluid-cursor/webgpu-engine.ts","content":"\"use client\";\n\n/// <reference types=\"@webgpu/types\" />\n// WebGPU HDR engine for FluidCursor.\n//\n// Port of the WebGL fluid simulation to WGSL, rendering into an\n// `rgba16float` canvas configured with `colorSpace: \"display-p3\"` and\n// `toneMapping: { mode: \"extended\" }`. On HDR displays, dye values pushed\n// above 1.0 by the exposure boost render brighter than SDR white; on wide\n// gamut (P3) displays colors are noticeably more saturated. Browsers that\n// ignore the `toneMapping` member simply clamp to SDR — the sim still runs.\n//\n// The math mirrors the GLSL shaders of the WebGL path pass for pass\n// (splat, curl, vorticity, divergence, clear, pressure Jacobi iterations,\n// gradient subtract, advection, display). Any behavioral change here must be\n// mirrored in the WebGL path and vice versa.\n\nimport {\n\ttype ColorRGB,\n\ttype Pointer,\n\ttype FluidCursorHandle,\n\ttype FluidRenderLevel,\n\tpointerPrototype,\n\tcorrectDeltaX,\n\tcorrectDeltaY,\n\tcorrectRadius,\n\tgetSimResolution,\n\tscaleRadiusForContainer,\n} from \"./fluid-shared\";\n\nexport interface WebGpuFluidOptions {\n\tsimResolution: number;\n\tdyeResolution: number;\n\tdensityDissipation: number;\n\tvelocityDissipation: number;\n\tpressure: number;\n\tpressureIterations: number;\n\tcurl: number;\n\tsplatRadius: number;\n\tsplatForce: number;\n\tshading: boolean;\n\tcolorUpdateSpeed: number;\n\t/** Display exposure multiplier — values above 1 create the HDR glow. */\n\texposure: number;\n\tcontained: boolean;\n\tinteractive: boolean;\n\tautoSplat: boolean;\n\tautoSplatInterval: number;\n\tpauseWhenHidden: boolean;\n\tsplatOnMount: boolean;\n\tgenerateColor: () => ColorRGB;\n\t/**\n\t * Multiplier a click applies to the (already intensity-scaled) colour\n\t * `generateColor()` returns — `clickBoost(colorIntensity)` from\n\t * fluid-shared, so the peak is the same whatever the intensity.\n\t */\n\tclickBoost: number;\n}\n\nconst WGSL = /* wgsl */ `\nstruct Uniforms {\n\ttexelSize: vec2f,\n\tpoint: vec2f,\n\tcolor: vec4f,\n\tdt: f32,\n\tdissipation: f32,\n\tcurlStrength: f32,\n\tclearValue: f32,\n\taspect: f32,\n\tradius: f32,\n\texposure: f32,\n\tshading: f32,\n}\n\n@group(0) @binding(0) var<uniform> u: Uniforms;\n@group(0) @binding(1) var smpLinear: sampler;\n@group(0) @binding(2) var smpNearest: sampler;\n@group(0) @binding(3) var tex0: texture_2d<f32>;\n@group(0) @binding(4) var tex1: texture_2d<f32>;\n\nstruct VSOut {\n\t@builtin(position) pos: vec4f,\n\t@location(0) uv: vec2f,\n}\n\n@vertex\nfn vs(@builtin(vertex_index) i: u32) -> VSOut {\n\t// Fullscreen triangle.\n\tvar p = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));\n\tvar out: VSOut;\n\tout.pos = vec4f(p[i], 0.0, 1.0);\n\t// Negated y so uv row order matches texture row order (WebGPU NDC +Y is\n\t// up but texture v=0 is the top row) — without it every pass mirrors its\n\t// input vertically. Pointer texcoords use the same top-origin convention.\n\tout.uv = vec2f(p[i].x, -p[i].y) * 0.5 + 0.5;\n\treturn out;\n}\n\n@fragment\nfn fs_copy(in: VSOut) -> @location(0) vec4f {\n\treturn textureSample(tex0, smpLinear, in.uv);\n}\n\n@fragment\nfn fs_splat(in: VSOut) -> @location(0) vec4f {\n\tvar p = in.uv - u.point;\n\tp.x = p.x * u.aspect;\n\tlet splat = exp(-dot(p, p) / u.radius) * u.color.rgb;\n\tlet base = textureSample(tex0, smpLinear, in.uv).rgb;\n\t// color.a is the click flag: a click splat lifts each texel to its own\n\t// profile instead of adding to it, so a burst of clicks paints the same\n\t// disc once instead of summing towards white and widening every click.\n\tlet sum = select(base + splat, max(base, splat), u.color.a > 0.5);\n\treturn vec4f(sum, 1.0);\n}\n\n@fragment\nfn fs_curl(in: VSOut) -> @location(0) vec4f {\n\tlet vL = in.uv - vec2f(u.texelSize.x, 0.0);\n\tlet vR = in.uv + vec2f(u.texelSize.x, 0.0);\n\tlet vT = in.uv + vec2f(0.0, u.texelSize.y);\n\tlet vB = in.uv - vec2f(0.0, u.texelSize.y);\n\tlet L = textureSample(tex0, smpLinear, vL).y;\n\tlet R = textureSample(tex0, smpLinear, vR).y;\n\tlet T = textureSample(tex0, smpLinear, vT).x;\n\tlet B = textureSample(tex0, smpLinear, vB).x;\n\tlet vorticity = R - L - T + B;\n\treturn vec4f(0.5 * vorticity, 0.0, 0.0, 1.0);\n}\n\n@fragment\nfn fs_vorticity(in: VSOut) -> @location(0) vec4f {\n\tlet vL = in.uv - vec2f(u.texelSize.x, 0.0);\n\tlet vR = in.uv + vec2f(u.texelSize.x, 0.0);\n\tlet vT = in.uv + vec2f(0.0, u.texelSize.y);\n\tlet vB = in.uv - vec2f(0.0, u.texelSize.y);\n\tlet L = textureSample(tex1, smpNearest, vL).x;\n\tlet R = textureSample(tex1, smpNearest, vR).x;\n\tlet T = textureSample(tex1, smpNearest, vT).x;\n\tlet B = textureSample(tex1, smpNearest, vB).x;\n\tlet C = textureSample(tex1, smpNearest, in.uv).x;\n\n\tvar force = 0.5 * vec2f(abs(T) - abs(B), abs(R) - abs(L));\n\tforce = force / (length(force) + 0.0001);\n\tforce = force * u.curlStrength * C;\n\tforce.y = force.y * -1.0;\n\n\tvar velocity = textureSample(tex0, smpLinear, in.uv).xy;\n\tvelocity = velocity + force * u.dt;\n\tvelocity = clamp(velocity, vec2f(-1000.0), vec2f(1000.0));\n\treturn vec4f(velocity, 0.0, 1.0);\n}\n\n@fragment\nfn fs_divergence(in: VSOut) -> @location(0) vec4f {\n\tlet vL = in.uv - vec2f(u.texelSize.x, 0.0);\n\tlet vR = in.uv + vec2f(u.texelSize.x, 0.0);\n\tlet vT = in.uv + vec2f(0.0, u.texelSize.y);\n\tlet vB = in.uv - vec2f(0.0, u.texelSize.y);\n\tvar L = textureSample(tex0, smpLinear, vL).x;\n\tvar R = textureSample(tex0, smpLinear, vR).x;\n\tvar T = textureSample(tex0, smpLinear, vT).y;\n\tvar B = textureSample(tex0, smpLinear, vB).y;\n\n\tlet C = textureSample(tex0, smpLinear, in.uv).xy;\n\tif (vL.x < 0.0) { L = -C.x; }\n\tif (vR.x > 1.0) { R = -C.x; }\n\tif (vT.y > 1.0) { T = -C.y; }\n\tif (vB.y < 0.0) { B = -C.y; }\n\n\tlet div = 0.5 * (R - L + T - B);\n\treturn vec4f(div, 0.0, 0.0, 1.0);\n}\n\n@fragment\nfn fs_clear(in: VSOut) -> @location(0) vec4f {\n\treturn u.clearValue * textureSample(tex0, smpNearest, in.uv);\n}\n\n@fragment\nfn fs_pressure(in: VSOut) -> @location(0) vec4f {\n\tlet vL = in.uv - vec2f(u.texelSize.x, 0.0);\n\tlet vR = in.uv + vec2f(u.texelSize.x, 0.0);\n\tlet vT = in.uv + vec2f(0.0, u.texelSize.y);\n\tlet vB = in.uv - vec2f(0.0, u.texelSize.y);\n\tlet L = textureSample(tex0, smpNearest, vL).x;\n\tlet R = textureSample(tex0, smpNearest, vR).x;\n\tlet T = textureSample(tex0, smpNearest, vT).x;\n\tlet B = textureSample(tex0, smpNearest, vB).x;\n\tlet divergence = textureSample(tex1, smpNearest, in.uv).x;\n\tlet pressure = (L + R + B + T - divergence) * 0.25;\n\treturn vec4f(pressure, 0.0, 0.0, 1.0);\n}\n\n@fragment\nfn fs_gradient(in: VSOut) -> @location(0) vec4f {\n\tlet vL = in.uv - vec2f(u.texelSize.x, 0.0);\n\tlet vR = in.uv + vec2f(u.texelSize.x, 0.0);\n\tlet vT = in.uv + vec2f(0.0, u.texelSize.y);\n\tlet vB = in.uv - vec2f(0.0, u.texelSize.y);\n\tlet L = textureSample(tex0, smpNearest, vL).x;\n\tlet R = textureSample(tex0, smpNearest, vR).x;\n\tlet T = textureSample(tex0, smpNearest, vT).x;\n\tlet B = textureSample(tex0, smpNearest, vB).x;\n\tvar velocity = textureSample(tex1, smpLinear, in.uv).xy;\n\tvelocity = velocity - vec2f(R - L, T - B);\n\treturn vec4f(velocity, 0.0, 1.0);\n}\n\n@fragment\nfn fs_advection(in: VSOut) -> @location(0) vec4f {\n\tlet coord = in.uv - u.dt * textureSample(tex0, smpLinear, in.uv).xy * u.texelSize;\n\tlet result = textureSample(tex1, smpLinear, coord);\n\tlet decay = 1.0 + u.dissipation * u.dt;\n\treturn result / decay;\n}\n\n@fragment\nfn fs_display(in: VSOut) -> @location(0) vec4f {\n\tvar c = textureSample(tex0, smpLinear, in.uv).rgb;\n\n\t// Neighbor samples are taken unconditionally and mixed by the shading\n\t// flag so texture sampling stays in uniform control flow.\n\tlet vL = in.uv - vec2f(u.texelSize.x, 0.0);\n\tlet vR = in.uv + vec2f(u.texelSize.x, 0.0);\n\tlet vT = in.uv + vec2f(0.0, u.texelSize.y);\n\tlet vB = in.uv - vec2f(0.0, u.texelSize.y);\n\tlet lc = textureSample(tex0, smpLinear, vL).rgb;\n\tlet rc = textureSample(tex0, smpLinear, vR).rgb;\n\tlet tc = textureSample(tex0, smpLinear, vT).rgb;\n\tlet bc = textureSample(tex0, smpLinear, vB).rgb;\n\tlet dx = length(rc) - length(lc);\n\tlet dy = length(tc) - length(bc);\n\tlet n = normalize(vec3f(dx, dy, length(u.texelSize)));\n\tlet l = vec3f(0.0, 0.0, 1.0);\n\tlet diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);\n\tc = c * mix(1.0, diffuse, u.shading);\n\n\t// The canvas color space keeps the sRGB transfer function even with a\n\t// float16 format (it only adds range/precision), so values are written\n\t// as-is like the WebGL path — no gamma conversion. The exposure boost\n\t// pushes encoded values above 1.0, which extended tone mapping renders\n\t// brighter than SDR white. Alpha stays clamped for premultiplied\n\t// compositing; color intentionally exceeds it for the HDR glow.\n\tlet a = min(max(c.r, max(c.g, c.b)), 1.0);\n\tlet boosted = max(c, vec3f(0.0)) * u.exposure;\n\treturn vec4f(boosted, a);\n}\n`;\n\n// Float offsets inside the 64-byte Uniforms struct.\nconst U_FLOATS = 16;\nconst OFF_TEXEL = 0;\nconst OFF_POINT = 2;\nconst OFF_COLOR = 4;\nconst OFF_DT = 8;\nconst OFF_DISSIPATION = 9;\nconst OFF_CURL = 10;\nconst OFF_CLEAR = 11;\nconst OFF_ASPECT = 12;\nconst OFF_RADIUS = 13;\nconst OFF_EXPOSURE = 14;\nconst OFF_SHADING = 15;\n\ninterface UniformValues {\n\ttexelSize?: [number, number];\n\tpoint?: [number, number];\n\t/** rgb, plus an optional 4th component the splat pass reads as its click flag. */\n\tcolor?: [number, number, number] | [number, number, number, number];\n\tdt?: number;\n\tdissipation?: number;\n\tcurlStrength?: number;\n\tclearValue?: number;\n\taspect?: number;\n\tradius?: number;\n\texposure?: number;\n\tshading?: number;\n}\n\ninterface DoubleTex {\n\tread: GPUTexture;\n\twrite: GPUTexture;\n\treadView: GPUTextureView;\n\twriteView: GPUTextureView;\n\twidth: number;\n\theight: number;\n\ttexelSizeX: number;\n\ttexelSizeY: number;\n\tswap: () => void;\n}\n\ninterface FluidEngine {\n\t/** `saturate` is the click mode: dye caps at `color` instead of adding to it. */\n\tsplat: (\n\t\tx: number,\n\t\ty: number,\n\t\tdx: number,\n\t\tdy: number,\n\t\tcolor: ColorRGB,\n\t\tsaturate?: boolean\n\t) => void;\n\tresizeIfNeeded: () => void;\n\tframe: (dt: number) => void;\n\treadonly lost: boolean;\n\t/** Whether the browser actually applied extended tone mapping (true HDR). */\n\treadonly extendedToneMapping: boolean;\n\tdestroy: () => void;\n}\n\nfunction scaleByPixelRatio(input: number) {\n\tconst pixelRatio = window.devicePixelRatio || 1;\n\treturn Math.floor(input * pixelRatio);\n}\n\nasync function createWebGpuFluid(\n\tcanvas: HTMLCanvasElement,\n\topts: WebGpuFluidOptions\n): Promise<FluidEngine | null> {\n\ttry {\n\t\tconst gpu = navigator.gpu;\n\t\tif (!gpu) return null;\n\t\tconst adapter = await gpu.requestAdapter();\n\t\tif (!adapter) return null;\n\t\tconst device = await adapter.requestDevice();\n\n\t\tconst format: GPUTextureFormat = \"rgba16float\";\n\t\t// `toneMapping` and `colorSpace` are ignored by browsers that do not\n\t\t// support them yet (WebIDL drops unknown dictionary members), in which\n\t\t// case output is clamped to SDR.\n\t\tconst configuration = {\n\t\t\tdevice,\n\t\t\tformat,\n\t\t\talphaMode: \"premultiplied\",\n\t\t\tcolorSpace: \"display-p3\",\n\t\t\ttoneMapping: { mode: \"extended\" },\n\t\t} as GPUCanvasConfiguration;\n\n\t\t// Probe the configuration on a detached canvas first. Once a canvas\n\t\t// has vended a \"webgpu\" context it can never provide a WebGL one, so\n\t\t// claiming the visible canvas before knowing configure() succeeds\n\t\t// would break the advertised WebGL fallback.\n\t\ttry {\n\t\t\tconst probe = document.createElement(\"canvas\").getContext(\"webgpu\");\n\t\t\tif (!probe) {\n\t\t\t\tdevice.destroy();\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tprobe.configure(configuration);\n\t\t\tprobe.unconfigure();\n\t\t} catch {\n\t\t\tdevice.destroy();\n\t\t\treturn null;\n\t\t}\n\n\t\tconst maybeContext = canvas.getContext(\"webgpu\");\n\t\tif (!maybeContext) {\n\t\t\tdevice.destroy();\n\t\t\treturn null;\n\t\t}\n\t\tconst context: GPUCanvasContext = maybeContext;\n\t\tcontext.configure(configuration);\n\n\t\t// Browsers that do not support toneMapping drop the member, which\n\t\t// getConfiguration() makes observable. Surface it as data so callers can\n\t\t// tell an extended-tone-mapping canvas from a clamped fallback; whether\n\t\t// the display is HDR is checked separately by startWebGpuFluid.\n\t\tlet extendedToneMapping = false;\n\t\ttry {\n\t\t\tconst applied = context.getConfiguration?.() as\n\t\t\t\t| (GPUCanvasConfiguration & { toneMapping?: { mode?: string } })\n\t\t\t\t| null;\n\t\t\textendedToneMapping = applied?.toneMapping?.mode === \"extended\";\n\t\t} catch {\n\t\t\t// getConfiguration unsupported (e.g. Safari): assume clamped output.\n\t\t}\n\n\t\tlet destroyed = false;\n\t\tlet lost = false;\n\t\tdevice.lost.then(() => {\n\t\t\tlost = true;\n\t\t});\n\n\t\tconst module = device.createShaderModule({ code: WGSL });\n\n\t\tconst bindGroupLayout = device.createBindGroupLayout({\n\t\t\tentries: [\n\t\t\t\t{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: \"uniform\" } },\n\t\t\t\t{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: \"filtering\" } },\n\t\t\t\t{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: \"filtering\" } },\n\t\t\t\t{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: \"float\" } },\n\t\t\t\t{ binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: \"float\" } },\n\t\t\t],\n\t\t});\n\t\tconst pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });\n\n\t\tfunction makePipeline(entryPoint: string, targetFormat: GPUTextureFormat) {\n\t\t\treturn device.createRenderPipeline({\n\t\t\t\tlayout: pipelineLayout,\n\t\t\t\tvertex: { module, entryPoint: \"vs\" },\n\t\t\t\tfragment: { module, entryPoint, targets: [{ format: targetFormat }] },\n\t\t\t\tprimitive: { topology: \"triangle-list\" },\n\t\t\t});\n\t\t}\n\n\t\tconst VEL_FORMAT: GPUTextureFormat = \"rg16float\";\n\t\tconst SCALAR_FORMAT: GPUTextureFormat = \"r16float\";\n\t\tconst DYE_FORMAT: GPUTextureFormat = \"rgba16float\";\n\n\t\tconst pipelines = {\n\t\t\tsplatVel: makePipeline(\"fs_splat\", VEL_FORMAT),\n\t\t\tsplatDye: makePipeline(\"fs_splat\", DYE_FORMAT),\n\t\t\tcurl: makePipeline(\"fs_curl\", SCALAR_FORMAT),\n\t\t\tvorticity: makePipeline(\"fs_vorticity\", VEL_FORMAT),\n\t\t\tdivergence: makePipeline(\"fs_divergence\", SCALAR_FORMAT),\n\t\t\tclear: makePipeline(\"fs_clear\", SCALAR_FORMAT),\n\t\t\tpressure: makePipeline(\"fs_pressure\", SCALAR_FORMAT),\n\t\t\tgradient: makePipeline(\"fs_gradient\", VEL_FORMAT),\n\t\t\tadvectVel: makePipeline(\"fs_advection\", VEL_FORMAT),\n\t\t\tadvectDye: makePipeline(\"fs_advection\", DYE_FORMAT),\n\t\t\tdisplay: makePipeline(\"fs_display\", format),\n\t\t\tcopyVel: makePipeline(\"fs_copy\", VEL_FORMAT),\n\t\t\tcopyDye: makePipeline(\"fs_copy\", DYE_FORMAT),\n\t\t};\n\n\t\tconst smpLinear = device.createSampler({\n\t\t\tmagFilter: \"linear\",\n\t\t\tminFilter: \"linear\",\n\t\t\taddressModeU: \"clamp-to-edge\",\n\t\t\taddressModeV: \"clamp-to-edge\",\n\t\t});\n\t\tconst smpNearest = device.createSampler({\n\t\t\tmagFilter: \"nearest\",\n\t\t\tminFilter: \"nearest\",\n\t\t\taddressModeU: \"clamp-to-edge\",\n\t\t\taddressModeV: \"clamp-to-edge\",\n\t\t});\n\n\t\tfunction makeUniformBuffer() {\n\t\t\treturn device.createBuffer({\n\t\t\t\tsize: U_FLOATS * 4,\n\t\t\t\tusage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,\n\t\t\t});\n\t\t}\n\n\t\tconst uSplatVel = makeUniformBuffer();\n\t\tconst uSplatDye = makeUniformBuffer();\n\t\tconst uCurl = makeUniformBuffer();\n\t\tconst uVorticity = makeUniformBuffer();\n\t\tconst uDivergence = makeUniformBuffer();\n\t\tconst uClear = makeUniformBuffer();\n\t\tconst uPressure = makeUniformBuffer();\n\t\tconst uGradient = makeUniformBuffer();\n\t\tconst uAdvectVel = makeUniformBuffer();\n\t\tconst uAdvectDye = makeUniformBuffer();\n\t\tconst uDisplay = makeUniformBuffer();\n\t\tconst uCopy = makeUniformBuffer();\n\n\t\tconst scratch = new Float32Array(U_FLOATS);\n\t\tfunction writeUniforms(buffer: GPUBuffer, v: UniformValues) {\n\t\t\tscratch.fill(0);\n\t\t\tif (v.texelSize) {\n\t\t\t\tscratch[OFF_TEXEL] = v.texelSize[0];\n\t\t\t\tscratch[OFF_TEXEL + 1] = v.texelSize[1];\n\t\t\t}\n\t\t\tif (v.point) {\n\t\t\t\tscratch[OFF_POINT] = v.point[0];\n\t\t\t\tscratch[OFF_POINT + 1] = v.point[1];\n\t\t\t}\n\t\t\tif (v.color) {\n\t\t\t\tscratch[OFF_COLOR] = v.color[0];\n\t\t\t\tscratch[OFF_COLOR + 1] = v.color[1];\n\t\t\t\tscratch[OFF_COLOR + 2] = v.color[2];\n\t\t\t\t// Only the splat pass reads .a (as its click flag); it defaults off.\n\t\t\t\tscratch[OFF_COLOR + 3] = v.color[3] ?? 0;\n\t\t\t}\n\t\t\tscratch[OFF_DT] = v.dt ?? 0;\n\t\t\tscratch[OFF_DISSIPATION] = v.dissipation ?? 0;\n\t\t\tscratch[OFF_CURL] = v.curlStrength ?? 0;\n\t\t\tscratch[OFF_CLEAR] = v.clearValue ?? 0;\n\t\t\tscratch[OFF_ASPECT] = v.aspect ?? 1;\n\t\t\tscratch[OFF_RADIUS] = v.radius ?? 1;\n\t\t\tscratch[OFF_EXPOSURE] = v.exposure ?? 1;\n\t\t\tscratch[OFF_SHADING] = v.shading ?? 0;\n\t\t\tdevice.queue.writeBuffer(buffer, 0, scratch);\n\t\t}\n\n\t\tfunction makeTexture(w: number, h: number, texFormat: GPUTextureFormat) {\n\t\t\treturn device.createTexture({\n\t\t\t\tsize: { width: w, height: h },\n\t\t\t\tformat: texFormat,\n\t\t\t\tusage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,\n\t\t\t});\n\t\t}\n\n\t\tfunction makeDoubleTex(w: number, h: number, texFormat: GPUTextureFormat): DoubleTex {\n\t\t\tconst a = makeTexture(w, h, texFormat);\n\t\t\tconst b = makeTexture(w, h, texFormat);\n\t\t\tconst target: DoubleTex = {\n\t\t\t\tread: a,\n\t\t\t\twrite: b,\n\t\t\t\treadView: a.createView(),\n\t\t\t\twriteView: b.createView(),\n\t\t\t\twidth: w,\n\t\t\t\theight: h,\n\t\t\t\ttexelSizeX: 1 / w,\n\t\t\t\ttexelSizeY: 1 / h,\n\t\t\t\tswap() {\n\t\t\t\t\tconst t = this.read;\n\t\t\t\t\tconst tv = this.readView;\n\t\t\t\t\tthis.read = this.write;\n\t\t\t\t\tthis.readView = this.writeView;\n\t\t\t\t\tthis.write = t;\n\t\t\t\t\tthis.writeView = tv;\n\t\t\t\t},\n\t\t\t};\n\t\t\treturn target;\n\t\t}\n\n\t\t// Bind groups are cached per (uniform buffer, texture views) combination\n\t\t// — the ping-pong swaps only cycle through a small finite set, and\n\t\t// recreating ~30 bind groups per frame (20 of them for the pressure\n\t\t// iterations) is measurable allocation/validation overhead. The cache\n\t\t// is cleared whenever textures are recreated (resize).\n\t\tconst bindGroupCache = new Map<string, GPUBindGroup>();\n\t\tconst resourceIds = new WeakMap<object, number>();\n\t\tlet nextResourceId = 0;\n\t\tfunction idOf(resource: object): number {\n\t\t\tlet id = resourceIds.get(resource);\n\t\t\tif (id === undefined) {\n\t\t\t\tid = nextResourceId++;\n\t\t\t\tresourceIds.set(resource, id);\n\t\t\t}\n\t\t\treturn id;\n\t\t}\n\t\tfunction getBindGroup(uniforms: GPUBuffer, tex0: GPUTextureView, tex1: GPUTextureView) {\n\t\t\tconst key = `${idOf(uniforms)}:${idOf(tex0)}:${idOf(tex1)}`;\n\t\t\tlet bindGroup = bindGroupCache.get(key);\n\t\t\tif (!bindGroup) {\n\t\t\t\tbindGroup = device.createBindGroup({\n\t\t\t\t\tlayout: bindGroupLayout,\n\t\t\t\t\tentries: [\n\t\t\t\t\t\t{ binding: 0, resource: { buffer: uniforms } },\n\t\t\t\t\t\t{ binding: 1, resource: smpLinear },\n\t\t\t\t\t\t{ binding: 2, resource: smpNearest },\n\t\t\t\t\t\t{ binding: 3, resource: tex0 },\n\t\t\t\t\t\t{ binding: 4, resource: tex1 },\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\t\tbindGroupCache.set(key, bindGroup);\n\t\t\t}\n\t\t\treturn bindGroup;\n\t\t}\n\n\t\tfunction runPass(\n\t\t\tencoder: GPUCommandEncoder,\n\t\t\tpipeline: GPURenderPipeline,\n\t\t\tuniforms: GPUBuffer,\n\t\t\ttex0: GPUTextureView,\n\t\t\ttex1: GPUTextureView,\n\t\t\ttarget: GPUTextureView\n\t\t) {\n\t\t\tconst bindGroup = getBindGroup(uniforms, tex0, tex1);\n\t\t\tconst pass = encoder.beginRenderPass({\n\t\t\t\tcolorAttachments: [\n\t\t\t\t\t{\n\t\t\t\t\t\tview: target,\n\t\t\t\t\t\tloadOp: \"clear\",\n\t\t\t\t\t\tstoreOp: \"store\",\n\t\t\t\t\t\tclearValue: { r: 0, g: 0, b: 0, a: 0 },\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t});\n\t\t\tpass.setPipeline(pipeline);\n\t\t\tpass.setBindGroup(0, bindGroup);\n\t\t\tpass.draw(3);\n\t\t\tpass.end();\n\t\t}\n\n\t\t// --- Simulation state ---\n\t\tlet dye: DoubleTex | null = null;\n\t\tlet velocity: DoubleTex | null = null;\n\t\tlet pressureTex: DoubleTex | null = null;\n\t\tlet divergenceTex: GPUTexture | null = null;\n\t\tlet divergenceView: GPUTextureView | null = null;\n\t\tlet curlTex: GPUTexture | null = null;\n\t\tlet curlView: GPUTextureView | null = null;\n\n\t\tfunction initTextures() {\n\t\t\tconst simRes = getSimResolution(opts.simResolution, canvas.width, canvas.height);\n\t\t\tconst dyeRes = getSimResolution(opts.dyeResolution, canvas.width, canvas.height);\n\n\t\t\tconst oldDye = dye;\n\t\t\tconst oldVelocity = velocity;\n\n\t\t\t// Canvas resize that leaves grid sizes unchanged (aspect-preserving):\n\t\t\t// keep the existing textures (mirrors resizeDoubleFBO's early-out).\n\t\t\tif (\n\t\t\t\toldDye &&\n\t\t\t\toldVelocity &&\n\t\t\t\toldDye.width === dyeRes.width &&\n\t\t\t\toldDye.height === dyeRes.height &&\n\t\t\t\toldVelocity.width === simRes.width &&\n\t\t\t\toldVelocity.height === simRes.height\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst newDye = makeDoubleTex(dyeRes.width, dyeRes.height, DYE_FORMAT);\n\t\t\tconst newVelocity = makeDoubleTex(simRes.width, simRes.height, VEL_FORMAT);\n\n\t\t\t// Preserve existing fluid across resizes by resampling the old\n\t\t\t// read textures into the new ones (mirrors resizeDoubleFBO).\n\t\t\tif (oldDye && oldVelocity) {\n\t\t\t\twriteUniforms(uCopy, {});\n\t\t\t\tconst encoder = device.createCommandEncoder();\n\t\t\t\trunPass(\n\t\t\t\t\tencoder,\n\t\t\t\t\tpipelines.copyDye,\n\t\t\t\t\tuCopy,\n\t\t\t\t\toldDye.readView,\n\t\t\t\t\toldDye.readView,\n\t\t\t\t\tnewDye.readView\n\t\t\t\t);\n\t\t\t\trunPass(\n\t\t\t\t\tencoder,\n\t\t\t\t\tpipelines.copyVel,\n\t\t\t\t\tuCopy,\n\t\t\t\t\toldVelocity.readView,\n\t\t\t\t\toldVelocity.readView,\n\t\t\t\t\tnewVelocity.readView\n\t\t\t\t);\n\t\t\t\tdevice.queue.submit([encoder.finish()]);\n\t\t\t\toldDye.read.destroy();\n\t\t\t\toldDye.write.destroy();\n\t\t\t\toldVelocity.read.destroy();\n\t\t\t\toldVelocity.write.destroy();\n\t\t\t}\n\n\t\t\tdye = newDye;\n\t\t\tvelocity = newVelocity;\n\n\t\t\t// Cached bind groups reference the destroyed texture views.\n\t\t\tbindGroupCache.clear();\n\n\t\t\tpressureTex?.read.destroy();\n\t\t\tpressureTex?.write.destroy();\n\t\t\tdivergenceTex?.destroy();\n\t\t\tcurlTex?.destroy();\n\t\t\tpressureTex = makeDoubleTex(simRes.width, simRes.height, SCALAR_FORMAT);\n\t\t\tdivergenceTex = makeTexture(simRes.width, simRes.height, SCALAR_FORMAT);\n\t\t\tdivergenceView = divergenceTex.createView();\n\t\t\tcurlTex = makeTexture(simRes.width, simRes.height, SCALAR_FORMAT);\n\t\t\tcurlView = curlTex.createView();\n\t\t}\n\n\t\tfunction resizeIfNeeded() {\n\t\t\tif (destroyed || lost) return;\n\t\t\tconst width = scaleByPixelRatio(canvas.clientWidth);\n\t\t\tconst height = scaleByPixelRatio(canvas.clientHeight);\n\t\t\tif (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {\n\t\t\t\tcanvas.width = width;\n\t\t\t\tcanvas.height = height;\n\t\t\t\tinitTextures();\n\t\t\t}\n\t\t}\n\n\t\tfunction splat(\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t\tdx: number,\n\t\t\tdy: number,\n\t\t\tcolor: ColorRGB,\n\t\t\tsaturate = false\n\t\t) {\n\t\t\tif (destroyed || lost || !velocity || !dye) return;\n\t\t\tlet radius = correctRadius(opts.splatRadius / 100, canvas.width, canvas.height);\n\t\t\tif (opts.contained) {\n\t\t\t\tradius = scaleRadiusForContainer(radius, canvas.clientHeight, window.innerHeight);\n\t\t\t}\n\t\t\tconst aspect = canvas.width / canvas.height;\n\n\t\t\twriteUniforms(uSplatVel, {\n\t\t\t\tpoint: [x, y],\n\t\t\t\tcolor: [dx, dy, 0],\n\t\t\t\taspect,\n\t\t\t\tradius,\n\t\t\t});\n\t\t\twriteUniforms(uSplatDye, {\n\t\t\t\tpoint: [x, y],\n\t\t\t\tcolor: [color.r, color.g, color.b, saturate ? 1 : 0],\n\t\t\t\taspect,\n\t\t\t\tradius,\n\t\t\t});\n\n\t\t\tconst encoder = device.createCommandEncoder();\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.splatVel,\n\t\t\t\tuSplatVel,\n\t\t\t\tvelocity.readView,\n\t\t\t\tvelocity.readView,\n\t\t\t\tvelocity.writeView\n\t\t\t);\n\t\t\tvelocity.swap();\n\t\t\trunPass(encoder, pipelines.splatDye, uSplatDye, dye.readView, dye.readView, dye.writeView);\n\t\t\tdye.swap();\n\t\t\tdevice.queue.submit([encoder.finish()]);\n\t\t}\n\n\t\tfunction frame(dt: number) {\n\t\t\tif (destroyed || lost || !velocity || !dye || !pressureTex || !divergenceView || !curlView) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst velTexel: [number, number] = [velocity.texelSizeX, velocity.texelSizeY];\n\t\t\twriteUniforms(uCurl, { texelSize: velTexel });\n\t\t\twriteUniforms(uVorticity, { texelSize: velTexel, curlStrength: opts.curl, dt });\n\t\t\twriteUniforms(uDivergence, { texelSize: velTexel });\n\t\t\twriteUniforms(uClear, { clearValue: opts.pressure });\n\t\t\twriteUniforms(uPressure, { texelSize: velTexel });\n\t\t\twriteUniforms(uGradient, { texelSize: velTexel });\n\t\t\twriteUniforms(uAdvectVel, {\n\t\t\t\ttexelSize: velTexel,\n\t\t\t\tdt,\n\t\t\t\tdissipation: opts.velocityDissipation,\n\t\t\t});\n\t\t\twriteUniforms(uAdvectDye, {\n\t\t\t\ttexelSize: velTexel,\n\t\t\t\tdt,\n\t\t\t\tdissipation: opts.densityDissipation,\n\t\t\t});\n\t\t\twriteUniforms(uDisplay, {\n\t\t\t\ttexelSize: [1 / canvas.width, 1 / canvas.height],\n\t\t\t\texposure: opts.exposure,\n\t\t\t\tshading: opts.shading ? 1 : 0,\n\t\t\t});\n\n\t\t\tconst encoder = device.createCommandEncoder();\n\n\t\t\trunPass(encoder, pipelines.curl, uCurl, velocity.readView, velocity.readView, curlView);\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.vorticity,\n\t\t\t\tuVorticity,\n\t\t\t\tvelocity.readView,\n\t\t\t\tcurlView,\n\t\t\t\tvelocity.writeView\n\t\t\t);\n\t\t\tvelocity.swap();\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.divergence,\n\t\t\t\tuDivergence,\n\t\t\t\tvelocity.readView,\n\t\t\t\tvelocity.readView,\n\t\t\t\tdivergenceView\n\t\t\t);\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.clear,\n\t\t\t\tuClear,\n\t\t\t\tpressureTex.readView,\n\t\t\t\tpressureTex.readView,\n\t\t\t\tpressureTex.writeView\n\t\t\t);\n\t\t\tpressureTex.swap();\n\n\t\t\tfor (let i = 0; i < opts.pressureIterations; i++) {\n\t\t\t\trunPass(\n\t\t\t\t\tencoder,\n\t\t\t\t\tpipelines.pressure,\n\t\t\t\t\tuPressure,\n\t\t\t\t\tpressureTex.readView,\n\t\t\t\t\tdivergenceView,\n\t\t\t\t\tpressureTex.writeView\n\t\t\t\t);\n\t\t\t\tpressureTex.swap();\n\t\t\t}\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.gradient,\n\t\t\t\tuGradient,\n\t\t\t\tpressureTex.readView,\n\t\t\t\tvelocity.readView,\n\t\t\t\tvelocity.writeView\n\t\t\t);\n\t\t\tvelocity.swap();\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.advectVel,\n\t\t\t\tuAdvectVel,\n\t\t\t\tvelocity.readView,\n\t\t\t\tvelocity.readView,\n\t\t\t\tvelocity.writeView\n\t\t\t);\n\t\t\tvelocity.swap();\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.advectDye,\n\t\t\t\tuAdvectDye,\n\t\t\t\tvelocity.readView,\n\t\t\t\tdye.readView,\n\t\t\t\tdye.writeView\n\t\t\t);\n\t\t\tdye.swap();\n\n\t\t\trunPass(\n\t\t\t\tencoder,\n\t\t\t\tpipelines.display,\n\t\t\t\tuDisplay,\n\t\t\t\tdye.readView,\n\t\t\t\tdye.readView,\n\t\t\t\tcontext.getCurrentTexture().createView()\n\t\t\t);\n\n\t\t\tdevice.queue.submit([encoder.finish()]);\n\t\t}\n\n\t\tinitTextures();\n\n\t\treturn {\n\t\t\tsplat,\n\t\t\tresizeIfNeeded,\n\t\t\tframe,\n\t\t\tget lost() {\n\t\t\t\treturn lost;\n\t\t\t},\n\t\t\textendedToneMapping,\n\t\t\tdestroy() {\n\t\t\t\tdestroyed = true;\n\t\t\t\tdevice.destroy();\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +\n * visibility pause + auto splats. Resolves with a {@link FluidCursorHandle}\n * (augmented with `cleanup`), or with `null` when WebGPU is unavailable\n * (caller falls back to the WebGL path).\n *\n * Handle coordinates are top-left origin in [0,1]; WebGPU texcoords already\n * use a top-origin convention, so no Y flip happens here (the WebGL path owns\n * its own flip).\n */\nexport async function startWebGpuFluid(\n\tcanvas: HTMLCanvasElement,\n\topts: WebGpuFluidOptions\n): Promise<(FluidCursorHandle & { cleanup(): void }) | null> {\n\tconst maybeEngine = await createWebGpuFluid(canvas, opts);\n\tif (!maybeEngine) return null;\n\tconst engine: FluidEngine = maybeEngine;\n\n\t// Non-empty by construction: index 0 is the real pointer for the life of\n\t// the engine, autopilot only ever appends.\n\tconst pointers: [Pointer, ...Pointer[]] = [pointerPrototype()];\n\t// Dedicated synthetic pointer driven programmatically through the handle. It\n\t// lives alongside the mouse pointer in the same list, so updateFrame splats\n\t// both and the two inputs coexist. Created on first programmatic use, so a\n\t// consumer that never drives the handle costs nothing.\n\tlet autopilotPointer: Pointer | null = null;\n\tlet penWasUp = true;\n\tfunction ensureAutopilotPointer(): Pointer {\n\t\tif (!autopilotPointer) {\n\t\t\tautopilotPointer = pointerPrototype();\n\t\t\tpointers.push(autopilotPointer);\n\t\t}\n\t\treturn autopilotPointer;\n\t}\n\n\tlet animationFrameId = 0;\n\tlet lastUpdateTime = Date.now();\n\tlet colorUpdateTimer = 0;\n\tlet isVisible = true;\n\tlet stopped = false;\n\n\t// --- Pointer position mapping (mirrors the WebGL path) ---\n\tlet canvasRectCache: DOMRect | null = null;\n\tfunction updateCanvasRectCache() {\n\t\tif (opts.contained) canvasRectCache = canvas.getBoundingClientRect();\n\t}\n\tfunction getCanvasPos(clientX: number, clientY: number): { x: number; y: number } {\n\t\tif (opts.contained) {\n\t\t\tif (!canvasRectCache) updateCanvasRectCache();\n\t\t\tconst rect = canvasRectCache!;\n\t\t\treturn {\n\t\t\t\tx: scaleByPixelRatio(clientX - rect.left),\n\t\t\t\ty: scaleByPixelRatio(clientY - rect.top),\n\t\t\t};\n\t\t}\n\t\treturn { x: scaleByPixelRatio(clientX), y: scaleByPixelRatio(clientY) };\n\t}\n\n\tfunction updatePointerDownData(pointer: Pointer, id: number, posX: number, posY: number) {\n\t\tpointer.id = id;\n\t\tpointer.down = true;\n\t\tpointer.moved = false;\n\t\tpointer.texcoordX = posX / canvas.width;\n\t\tpointer.texcoordY = posY / canvas.height;\n\t\tpointer.prevTexcoordX = pointer.texcoordX;\n\t\tpointer.prevTexcoordY = pointer.texcoordY;\n\t\tpointer.deltaX = 0;\n\t\tpointer.deltaY = 0;\n\t\tpointer.color = opts.generateColor();\n\t}\n\n\tfunction updatePointerMoveData(pointer: Pointer, posX: number, posY: number, color: ColorRGB) {\n\t\tpointer.prevTexcoordX = pointer.texcoordX;\n\t\tpointer.prevTexcoordY = pointer.texcoordY;\n\t\tpointer.texcoordX = posX / canvas.width;\n\t\tpointer.texcoordY = posY / canvas.height;\n\t\tpointer.deltaX = correctDeltaX(\n\t\t\tpointer.texcoordX - pointer.prevTexcoordX,\n\t\t\tcanvas.width,\n\t\t\tcanvas.height\n\t\t);\n\t\tpointer.deltaY = correctDeltaY(\n\t\t\tpointer.texcoordY - pointer.prevTexcoordY,\n\t\t\tcanvas.width,\n\t\t\tcanvas.height\n\t\t);\n\t\tpointer.moved = Math.abs(pointer.deltaX) > 0 || Math.abs(pointer.deltaY) > 0;\n\t\tpointer.color = color;\n\t}\n\n\tfunction splatPointer(pointer: Pointer) {\n\t\tconst dx = pointer.deltaX * opts.splatForce;\n\t\tconst dy = pointer.deltaY * opts.splatForce;\n\t\tengine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);\n\t}\n\n\tfunction clickSplat(pointer: Pointer) {\n\t\t// COPY, never scale in place: with `fluidColor` / `fluidColors` set,\n\t\t// generateColor() hands back the cached palette object itself, and scaling\n\t\t// that multiplied the palette again on every click.\n\t\tconst base = opts.generateColor();\n\t\tconst color = {\n\t\t\tr: base.r * opts.clickBoost,\n\t\t\tg: base.g * opts.clickBoost,\n\t\t\tb: base.b * opts.clickBoost,\n\t\t};\n\t\tconst dx = 10 * (Math.random() - 0.5);\n\t\tconst dy = 30 * (Math.random() - 0.5);\n\t\t// Saturating: a burst of clicks on one spot pools at this colour rather\n\t\t// than summing towards white (a click has next to no velocity).\n\t\tengine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);\n\t}\n\n\tfunction multipleSplats(steps: number) {\n\t\t// Simulate a cursor sweep along a random arc — each step on its own\n\t\t// frame so the velocity field has time to evolve between injections.\n\t\tconst angle = Math.random() * Math.PI * 2;\n\t\tconst cx = 0.25 + Math.random() * 0.5;\n\t\tconst cy = 0.25 + Math.random() * 0.5;\n\t\tconst arcSpan = Math.PI * (0.8 + Math.random() * 0.8);\n\t\tlet i = 0;\n\n\t\tfunction step() {\n\t\t\tif (stopped || i >= steps) return;\n\t\t\tconst t = i / Math.max(1, steps - 1);\n\t\t\tconst a = angle + t * arcSpan;\n\t\t\tconst r = 0.15 + 0.05 * Math.sin(t * Math.PI);\n\t\t\tconst x = cx + Math.cos(a) * r;\n\t\t\tconst y = cy + Math.sin(a) * r;\n\t\t\tconst color = opts.generateColor();\n\t\t\tengine.splat(\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\t-Math.sin(a) * opts.splatForce * 0.5,\n\t\t\t\tMath.cos(a) * opts.splatForce * 0.5,\n\t\t\t\tcolor\n\t\t\t);\n\t\t\ti++;\n\t\t\tif (i < steps) requestAnimationFrame(step);\n\t\t}\n\t\trequestAnimationFrame(step);\n\t}\n\n\t// --- Frame loop (same ordering as the WebGL path: resize, colors,\n\t// inputs, then simulation step + render) ---\n\tfunction updateFrame() {\n\t\tif (stopped || !isVisible) return;\n\t\t// Device loss (GPU reset, driver update): stop the loop instead of\n\t\t// spinning on a dead device. The effect simply disappears.\n\t\tif (engine.lost) return;\n\t\tconst now = Date.now();\n\t\tconst dt = Math.min((now - lastUpdateTime) / 1000, 0.016666);\n\t\tlastUpdateTime = now;\n\n\t\tengine.resizeIfNeeded();\n\n\t\tcolorUpdateTimer += dt * opts.colorUpdateSpeed;\n\t\tif (colorUpdateTimer >= 1) {\n\t\t\tcolorUpdateTimer = colorUpdateTimer % 1;\n\t\t\tpointers.forEach((p) => {\n\t\t\t\t// The synthetic pointer's color belongs to the handle: pulling from\n\t\t\t\t// the generator here would advance the shared palette index twice per\n\t\t\t\t// update and overwrite explicit stroke colors.\n\t\t\t\tif (p === autopilotPointer) return;\n\t\t\t\tp.color = opts.generateColor();\n\t\t\t});\n\t\t}\n\n\t\tfor (const p of pointers) {\n\t\t\tif (p.moved) {\n\t\t\t\tp.moved = false;\n\t\t\t\tsplatPointer(p);\n\t\t\t}\n\t\t}\n\n\t\tengine.frame(dt);\n\t\tanimationFrameId = requestAnimationFrame(updateFrame);\n\t}\n\n\t// --- Event listeners ---\n\tfunction handleMouseDown(e: MouseEvent) {\n\t\tconst pointer = pointers[0];\n\t\tconst { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);\n\t\tupdatePointerDownData(pointer, -1, posX, posY);\n\t\tclickSplat(pointer);\n\t}\n\n\t// The release half of mousedown / touchstart — `down` used to latch true for\n\t// the life of the engine.\n\tfunction handlePointerUp() {\n\t\tpointers[0].down = false;\n\t}\n\n\tfunction handleFirstMouseMove(e: MouseEvent) {\n\t\tconst pointer = pointers[0];\n\t\tconst { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);\n\t\tupdatePointerMoveData(pointer, posX, posY, opts.generateColor());\n\t\tdocument.body.removeEventListener(\"mousemove\", handleFirstMouseMove);\n\t}\n\n\tfunction handleMouseMove(e: MouseEvent) {\n\t\tconst pointer = pointers[0];\n\t\tconst { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);\n\t\tupdatePointerMoveData(pointer, posX, posY, pointer.color);\n\t}\n\n\tfunction handleFirstTouchStart(e: TouchEvent) {\n\t\tconst touches = e.targetTouches;\n\t\tconst pointer = pointers[0];\n\t\tfor (let i = 0; i < touches.length; i++) {\n\t\t\tconst touch = touches[i];\n\t\t\tif (!touch) continue;\n\t\t\tconst { x: posX, y: posY } = getCanvasPos(touch.clientX, touch.clientY);\n\t\t\tupdatePointerDownData(pointer, touch.identifier, posX, posY);\n\t\t}\n\t\tdocument.body.removeEventListener(\"touchstart\", handleFirstTouchStart);\n\t}\n\n\tfunction handleTouchStart(e: TouchEvent) {\n\t\tconst touches = e.targetTouches;\n\t\tconst pointer = pointers[0];\n\t\tfor (let i = 0; i < touches.length; i++) {\n\t\t\tconst touch = touches[i];\n\t\t\tif (!touch) continue;\n\t\t\tconst { x: posX, y: posY } = getCanvasPos(touch.clientX, touch.clientY);\n\t\t\tupdatePointerDownData(pointer, touch.identifier, posX, posY);\n\t\t}\n\t}\n\n\tfunction handleTouchMove(e: TouchEvent) {\n\t\tconst touches = e.targetTouches;\n\t\tconst pointer = pointers[0];\n\t\tfor (let i = 0; i < touches.length; i++) {\n\t\t\tconst touch = touches[i];\n\t\t\tif (!touch) continue;\n\t\t\tconst { x: posX, y: posY } = getCanvasPos(touch.clientX, touch.clientY);\n\t\t\tupdatePointerMoveData(pointer, posX, posY, pointer.color);\n\t\t}\n\t}\n\n\tif (opts.contained) {\n\t\twindow.addEventListener(\"resize\", updateCanvasRectCache, { passive: true });\n\t\twindow.addEventListener(\"scroll\", updateCanvasRectCache, { passive: true });\n\t\tupdateCanvasRectCache();\n\t}\n\n\tif (opts.interactive) {\n\t\twindow.addEventListener(\"mousedown\", handleMouseDown);\n\t\twindow.addEventListener(\"mouseup\", handlePointerUp);\n\t\tdocument.body.addEventListener(\"mousemove\", handleFirstMouseMove);\n\t\twindow.addEventListener(\"mousemove\", handleMouseMove);\n\t\tdocument.body.addEventListener(\"touchstart\", handleFirstTouchStart, { passive: true });\n\t\twindow.addEventListener(\"touchstart\", handleTouchStart, { passive: true });\n\t\twindow.addEventListener(\"touchmove\", handleTouchMove, { passive: true });\n\t\twindow.addEventListener(\"touchend\", handlePointerUp);\n\t\twindow.addEventListener(\"touchcancel\", handlePointerUp);\n\t}\n\n\tlet observer: IntersectionObserver | null = null;\n\tif (opts.pauseWhenHidden) {\n\t\tobserver = new IntersectionObserver(\n\t\t\t(entries) => {\n\t\t\t\tconst entry = entries[0];\n\t\t\t\tif (!entry) return;\n\t\t\t\tconst wasVisible = isVisible;\n\t\t\t\tisVisible = entry.isIntersecting;\n\t\t\t\tif (isVisible && !wasVisible && !stopped) {\n\t\t\t\t\tlastUpdateTime = Date.now();\n\t\t\t\t\tanimationFrameId = requestAnimationFrame(updateFrame);\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ threshold: 0 }\n\t\t);\n\t\tobserver.observe(canvas);\n\t}\n\n\tupdateFrame();\n\tif (opts.splatOnMount) multipleSplats(Math.floor(Math.random() * 6) + 10);\n\n\tlet autoSplatTimer: ReturnType<typeof setInterval> | null = null;\n\tif (opts.autoSplat) {\n\t\tautoSplatTimer = setInterval(() => {\n\t\t\tengine.splat(\n\t\t\t\tMath.random(),\n\t\t\t\tMath.random(),\n\t\t\t\t(Math.random() - 0.5) * 200,\n\t\t\t\t(Math.random() - 0.5) * 200,\n\t\t\t\topts.generateColor()\n\t\t\t);\n\t\t}, opts.autoSplatInterval);\n\t}\n\n\t// Extended tone mapping only proves the browser kept the configuration — an\n\t// SDR display still clamps the output — so \"webgpu-hdr\" requires both. The\n\t// display state is sampled once here and does not follow the window to\n\t// another monitor.\n\tconst hdrDisplay =\n\t\ttypeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n\t\t\t? window.matchMedia(\"(dynamic-range: high)\").matches\n\t\t\t: false;\n\tconst renderLevel: FluidRenderLevel =\n\t\tengine.extendedToneMapping && hdrDisplay ? \"webgpu-hdr\" : \"webgpu-sdr\";\n\n\treturn {\n\t\t/**\n\t\t * x,y in [0,1], top-left origin. WebGPU texcoords are already top-origin,\n\t\t * so the coordinates pass straight through.\n\t\t */\n\t\tmoveTo(x: number, y: number, color?: ColorRGB) {\n\t\t\tconst pointer = ensureAutopilotPointer();\n\t\t\tconst posX = x * canvas.width;\n\t\t\tconst posY = y * canvas.height;\n\t\t\tif (penWasUp) {\n\t\t\t\t// First move of a new stroke: reposition only (mirrors\n\t\t\t\t// updatePointerDownData) so the jump from the previous stroke's\n\t\t\t\t// end never draws a connecting streak.\n\t\t\t\tpenWasUp = false;\n\t\t\t\tpointer.down = true;\n\t\t\t\tpointer.moved = false;\n\t\t\t\tpointer.texcoordX = posX / canvas.width;\n\t\t\t\tpointer.texcoordY = posY / canvas.height;\n\t\t\t\tpointer.prevTexcoordX = pointer.texcoordX;\n\t\t\t\tpointer.prevTexcoordY = pointer.texcoordY;\n\t\t\t\tpointer.deltaX = 0;\n\t\t\t\tpointer.deltaY = 0;\n\t\t\t\t// A stroke with no explicit color takes a fresh generated one, as a\n\t\t\t\t// real pointer-down does: the prototype's black would inject\n\t\t\t\t// invisible dye.\n\t\t\t\tpointer.color = color ?? opts.generateColor();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tupdatePointerMoveData(pointer, posX, posY, color ?? pointer.color);\n\t\t},\n\t\tpenUp() {\n\t\t\t// `moved` is deliberately left alone: updateFrame consumes and clears it\n\t\t\t// on the next frame, so clearing it here would swallow the final segment\n\t\t\t// of a stroke finished synchronously before that frame.\n\t\t\tpenWasUp = true;\n\t\t\tif (autopilotPointer) autopilotPointer.down = false;\n\t\t},\n\t\t/** No Y flip on the WebGPU path. */\n\t\tburst(x: number, y: number, dx: number, dy: number, color: ColorRGB) {\n\t\t\tengine.splat(x, y, dx, dy, color);\n\t\t},\n\t\trenderLevel,\n\t\tcleanup() {\n\t\t\tstopped = true;\n\t\t\tcancelAnimationFrame(animationFrameId);\n\t\t\tif (observer) observer.disconnect();\n\t\t\tif (autoSplatTimer) clearInterval(autoSplatTimer);\n\t\t\tif (opts.interactive) {\n\t\t\t\twindow.removeEventListener(\"mousedown\", handleMouseDown);\n\t\t\t\twindow.removeEventListener(\"mouseup\", handlePointerUp);\n\t\t\t\tdocument.body.removeEventListener(\"mousemove\", handleFirstMouseMove);\n\t\t\t\twindow.removeEventListener(\"mousemove\", handleMouseMove);\n\t\t\t\tdocument.body.removeEventListener(\"touchstart\", handleFirstTouchStart);\n\t\t\t\twindow.removeEventListener(\"touchstart\", handleTouchStart);\n\t\t\t\twindow.removeEventListener(\"touchmove\", handleTouchMove);\n\t\t\t\twindow.removeEventListener(\"touchend\", handlePointerUp);\n\t\t\t\twindow.removeEventListener(\"touchcancel\", handlePointerUp);\n\t\t\t}\n\t\t\tif (opts.contained) {\n\t\t\t\twindow.removeEventListener(\"resize\", updateCanvasRectCache);\n\t\t\t\twindow.removeEventListener(\"scroll\", updateCanvasRectCache);\n\t\t\t}\n\t\t\tengine.destroy();\n\t\t},\n\t};\n}\n","type":"registry:ui","target":"@ui/fancy/components/fluid-cursor/webgpu-engine.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/fluid-cursor?framework=react"}}