@wave3d/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +3 -1
  2. package/dist/config/model.d.ts +78 -4
  3. package/dist/config/model.js +12 -2
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/presets.js +35 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveRenderer.d.ts +146 -12
  9. package/dist/renderer/WaveRenderer.js +218 -56
  10. package/dist/renderer/WaveRenderer.js.map +1 -1
  11. package/dist/renderer/WaveRendererGPU.js +291 -0
  12. package/dist/renderer/WaveRendererGPU.js.map +1 -0
  13. package/dist/renderer/gpu-loader.js +2 -0
  14. package/dist/renderer/index.d.ts +3 -2
  15. package/dist/renderer/interaction.d.ts +29 -0
  16. package/dist/renderer/interaction.js +77 -31
  17. package/dist/renderer/interaction.js.map +1 -1
  18. package/dist/renderer/interactionGates.js +59 -0
  19. package/dist/renderer/interactionGates.js.map +1 -0
  20. package/dist/renderer/particleField.d.ts +1 -28
  21. package/dist/renderer/particleField.js +173 -16
  22. package/dist/renderer/particleField.js.map +1 -1
  23. package/dist/renderer/particleFieldGPU.js +141 -0
  24. package/dist/renderer/particleFieldGPU.js.map +1 -0
  25. package/dist/renderer/shaders.js +178 -86
  26. package/dist/renderer/shaders.js.map +1 -1
  27. package/dist/renderer/tilt.d.ts +17 -0
  28. package/dist/renderer/tilt.js +124 -0
  29. package/dist/renderer/tilt.js.map +1 -0
  30. package/dist/renderer/tsl/color.js +129 -0
  31. package/dist/renderer/tsl/color.js.map +1 -0
  32. package/dist/renderer/tsl/noise.js +83 -0
  33. package/dist/renderer/tsl/noise.js.map +1 -0
  34. package/dist/renderer/tsl/packedArray.js +76 -0
  35. package/dist/renderer/tsl/packedArray.js.map +1 -0
  36. package/dist/renderer/tsl/particleMaterial.js +133 -0
  37. package/dist/renderer/tsl/particleMaterial.js.map +1 -0
  38. package/dist/renderer/tsl/particleUniforms.js +44 -0
  39. package/dist/renderer/tsl/particleUniforms.js.map +1 -0
  40. package/dist/renderer/tsl/pointerField.js +73 -0
  41. package/dist/renderer/tsl/pointerField.js.map +1 -0
  42. package/dist/renderer/tsl/post.js +63 -0
  43. package/dist/renderer/tsl/post.js.map +1 -0
  44. package/dist/renderer/tsl/postChain.js +56 -0
  45. package/dist/renderer/tsl/postChain.js.map +1 -0
  46. package/dist/renderer/tsl/postEffects.js +221 -0
  47. package/dist/renderer/tsl/postEffects.js.map +1 -0
  48. package/dist/renderer/tsl/types.js +28 -0
  49. package/dist/renderer/tsl/types.js.map +1 -0
  50. package/dist/renderer/tsl/uniforms.js +152 -0
  51. package/dist/renderer/tsl/uniforms.js.map +1 -0
  52. package/dist/renderer/tsl/waveMaterial.js +185 -0
  53. package/dist/renderer/tsl/waveMaterial.js.map +1 -0
  54. package/dist/renderer/tsl/waveShape.js +108 -0
  55. package/dist/renderer/tsl/waveShape.js.map +1 -0
  56. package/dist/shell/createWave.d.ts +29 -0
  57. package/dist/shell/createWave.js +39 -11
  58. package/dist/shell/createWave.js.map +1 -1
  59. package/dist/shell/probe.js +18 -1
  60. package/dist/shell/probe.js.map +1 -1
  61. package/dist/standalone/wave3d.standalone.js +2219 -1929
  62. package/dist/standalone/wave3d.standalone.webgpu.js +36647 -0
  63. package/dist/standalone.d.ts +10 -3
  64. package/dist/standalone.js +10 -3
  65. package/dist/standalone.js.map +1 -1
  66. package/dist/studio/StudioWaveRenderer.d.ts +4 -0
  67. package/dist/studio/StudioWaveRenderer.js +9 -0
  68. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  69. package/dist/studio/StudioWaveRendererGPU.js +18 -0
  70. package/dist/studio/StudioWaveRendererGPU.js.map +1 -0
  71. package/dist/studio/index.d.ts +12 -2
  72. package/dist/studio/index.js +14 -1
  73. package/dist/studio/index.js.map +1 -0
  74. package/package.json +12 -3
  75. package/skills/wave3d/SKILL.md +29 -2
@@ -1 +1 @@
1
- {"version":3,"file":"particleField.js","names":[],"sources":["../../src/renderer/particleField.ts"],"sourcesContent":["import * as THREE from \"three\";\nimport type { ParticlesConfig } from \"../config/model\";\nimport { particleFragmentShader, particleVertexShader } from \"./shaders\";\n\n/**\n * A WAVE's additive particle / dust field: a single {@link THREE.Points} whose every particle is placed\n * and animated ENTIRELY in the vertex shader from `uTime` + baked per-particle attributes, so the field\n * is deterministic — the same `(count, seed, edgeBias, bias)` yields byte-identical buffers, and all\n * motion is a pure function of the scene time `t` (so timeOffset scrub / loopSeconds / paused reproduce).\n *\n * One field belongs to ONE wave (created/disposed alongside it, like {@link WavePalette}). Every particle\n * spawns on that wave's DEFORMED surface / edge (via the shared waveShape chunk, riding the exact deform\n * the ribbon uses) and drifts outward from the wave centre. Byte-identical when off: absent\n * `wave.particles` ⇒ the renderer never creates a field.\n */\nexport interface ParticleFrame {\n /** The owning wave's world-space centre — drift radiates from here. */\n center: THREE.Vector3;\n /** Screen-right / screen-up unit vectors (world space) for screen-relative motion. */\n right: THREE.Vector3;\n up: THREE.Vector3;\n}\n\n/** Deterministic PRNG (mulberry32): a pure function of the seed, so a `(count, seed)` layout\n * reproduces exactly. NEVER Math.random — that would desync timeOffset scrub / loop / paused. */\nfunction mulberry32(seed: number): () => number {\n let a = seed >>> 0;\n return () => {\n a |= 0;\n a = (a + 0x6d2b79f5) | 0;\n let t = Math.imul(a ^ (a >>> 15), 1 | a);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n// sRGB hex → linear RGB. Replicates WaveRenderer.hexToLinearVec3 locally to avoid a circular import\n// (the renderer imports this module): three's ColorManagement linearizes on parse, so read .r/.g/.b\n// directly — a second convertSRGBToLinear() would double-linearize.\nconst HEX_SCRATCH = new THREE.Color();\nfunction setLinear(target: THREE.Vector3, hex: string): void {\n const c = HEX_SCRATCH.set(hex);\n target.set(c.r, c.g, c.b);\n}\n\nconst DEFAULT_COLOR = \"#ffcf8a\"; // warm gold\n\n/** Sprite-shape name → the int the fragment shader branches on (see particleFragmentShader). */\nconst SHAPE_INDEX: Record<string, number> = { glitter: 0, soft: 1, ring: 2, star: 3, streak: 4 };\n\n/** The owning wave's shape uniforms mirrored onto the particle material so the dust rides the same\n * deform as the ribbon. Names match the wave material's uniforms 1:1 (copied by value in configure). */\nconst SHAPE_UNIFORMS = [\n \"uDispFreqX\",\n \"uDispFreqZ\",\n \"uDispAmount\",\n \"uDetailFreq\",\n \"uDetailAmount\",\n \"uTwFreqX\",\n \"uTwFreqY\",\n \"uTwFreqZ\",\n \"uTwPowX\",\n \"uTwPowY\",\n \"uTwPowZ\",\n \"uHelixTurns\",\n \"uHelixRadius\",\n \"uHelixRoll\",\n \"uHelixPhase\",\n \"uRadialAmount\",\n \"uRadialArc\",\n \"uRadialSpread\",\n \"uRadialRadius\",\n \"uRadialCenter\",\n] as const;\n\n/** Build the seeded per-particle attribute buffers. Pure function of `(count, seed, edgeBias, bias)` —\n * exported so a unit test can assert reproducibility without a GPU. */\nexport function buildParticleAttributes(\n count: number,\n seed: number,\n edgeBias = 1,\n bias = 0,\n): {\n position: Float32Array;\n aSeed: Float32Array;\n aRnd: Float32Array;\n aUv: Float32Array;\n} {\n const rand = mulberry32(seed >>> 0 || 1);\n const position = new Float32Array(count * 3); // dummy — the vertex shader computes real positions\n const aSeed = new Float32Array(count);\n const aRnd = new Float32Array(count * 4);\n const aUv = new Float32Array(count * 2);\n for (let i = 0; i < count; i++) {\n aSeed[i] = rand();\n aRnd[i * 4 + 0] = rand();\n aRnd[i * 4 + 1] = rand();\n aRnd[i * 4 + 2] = rand();\n aRnd[i * 4 + 3] = rand();\n }\n // aUv in a SEPARATE pass so adding/changing it never shifts the aSeed/aRnd RNG sequence above. aUv.x\n // = the flank position across the ribbon width; `bias` skews it toward one side (`u^p`, p<1 crowds\n // →1, p>1 crowds →0; p=1 / bias 0 leaves it untouched). aUv.y = WHERE ALONG the ribbon the particle\n // spawns, interpolated by `edgeBias`: 0 → uniform across the whole SURFACE, 1 → crowded to the outer\n // rim/EDGE (`1 − rand²·0.45`, the silk-dissolving-into-glitter look).\n const pexp = bias === 0 ? 1 : Math.exp(-bias * 2);\n const eb = Math.min(Math.max(edgeBias, 0), 1);\n for (let i = 0; i < count; i++) {\n const ux = rand();\n aUv[i * 2 + 0] = bias === 0 ? ux : Math.pow(ux, pexp);\n const e = rand();\n const rim = 1.0 - e * e * 0.45; // outer-rim biased\n aUv[i * 2 + 1] = e + (rim - e) * eb; // mix(surface, edge) by edgeBias\n }\n return { position, aSeed, aRnd, aUv };\n}\n\nexport class ParticleField {\n readonly points: THREE.Points;\n private readonly geometry = new THREE.BufferGeometry();\n private readonly material: THREE.ShaderMaterial;\n /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */\n private sig = \"\";\n\n constructor() {\n this.material = new THREE.ShaderMaterial({\n uniforms: {\n uTime: { value: 0 },\n uLoopSeconds: { value: 0 },\n uLife: { value: 6 },\n uPartSpeed: { value: 1 },\n uSize: { value: 2 },\n uSizeJitter: { value: 0 },\n uTwinkle: { value: 0 },\n uPixelRatio: { value: 1 },\n uColor: { value: new THREE.Vector3(1, 0.81, 0.54) },\n uColor2: { value: new THREE.Vector3(1, 0.81, 0.54) },\n uCenter: { value: new THREE.Vector3() },\n uRight: { value: new THREE.Vector3(1, 0, 0) },\n uUp: { value: new THREE.Vector3(0, 1, 0) },\n uDrift: { value: 0 },\n uRise: { value: 0 },\n uSwirl: { value: 0 },\n uWander: { value: 0 },\n uShape: { value: 0 },\n // The owning wave's shape uniforms + world matrix, mirrored in configure() so the dust rides\n // the same deform as the ribbon. The nested HELIX/RADIAL uniforms are only declared (and\n // uploaded) when the matching #define is set — the byte-identity precedent from the wave material.\n uDispFreqX: { value: 0 },\n uDispFreqZ: { value: 0 },\n uDispAmount: { value: 0 },\n uDetailFreq: { value: 0 },\n uDetailAmount: { value: 0 },\n uTwFreqX: { value: 0 },\n uTwFreqY: { value: 0 },\n uTwFreqZ: { value: 0 },\n uTwPowX: { value: 0 },\n uTwPowY: { value: 0 },\n uTwPowZ: { value: 0 },\n uHelixTurns: { value: 0 },\n uHelixRadius: { value: 0 },\n uHelixRoll: { value: 0 },\n uHelixPhase: { value: 0 },\n uRadialAmount: { value: 0 },\n uRadialArc: { value: 0 },\n uRadialSpread: { value: 0 },\n uRadialRadius: { value: 0 },\n uRadialCenter: { value: 0 },\n uShedModel: { value: new THREE.Matrix4() },\n uShedSpeed: { value: 0 },\n uShedSeed: { value: 0 },\n },\n vertexShader: particleVertexShader,\n fragmentShader: particleFragmentShader,\n transparent: true,\n depthTest: false, // always composite OVER the waves...\n depthWrite: false, // ...and never occlude anything (additive glints)\n blending: THREE.AdditiveBlending,\n });\n this.points = new THREE.Points(this.geometry, this.material);\n this.points.frustumCulled = false; // positions are shader-computed; the base geometry is dummy\n this.points.renderOrder = 10; // after the waves (0..5)\n }\n\n /** Reconcile to `cfg`: rebuild the seeded buffers if the layout signature changed, then push the\n * frame-independent uniforms. `loopSeconds` is scene-level (passed in). Called from refresh(). */\n sync(cfg: ParticlesConfig, loopSeconds: number): void {\n const count = Math.max(0, Math.floor(cfg.count));\n const edgeBias = cfg.edgeBias ?? 1;\n const bias = cfg.bias ?? 0;\n const sig = `${count}|${cfg.seed}|${edgeBias}|${bias}`;\n if (sig !== this.sig) {\n this.sig = sig;\n const { position, aSeed, aRnd, aUv } = buildParticleAttributes(\n count,\n cfg.seed,\n edgeBias,\n bias,\n );\n this.geometry.setAttribute(\"position\", new THREE.BufferAttribute(position, 3));\n this.geometry.setAttribute(\"aSeed\", new THREE.BufferAttribute(aSeed, 1));\n this.geometry.setAttribute(\"aRnd\", new THREE.BufferAttribute(aRnd, 4));\n this.geometry.setAttribute(\"aUv\", new THREE.BufferAttribute(aUv, 2));\n this.geometry.setDrawRange(0, count);\n }\n const u = this.material.uniforms;\n u.uLoopSeconds.value = loopSeconds;\n u.uLife.value = cfg.life ?? 6;\n u.uPartSpeed.value = cfg.speed ?? 1;\n u.uSize.value = cfg.size;\n u.uSizeJitter.value = cfg.sizeJitter ?? 0;\n u.uTwinkle.value = cfg.twinkle ?? 0;\n u.uDrift.value = cfg.drift ?? 0;\n u.uRise.value = cfg.rise ?? 0;\n u.uSwirl.value = cfg.swirl ?? 0;\n u.uWander.value = cfg.wander ?? 0;\n u.uShape.value = SHAPE_INDEX[cfg.shape ?? \"glitter\"] ?? 0;\n setLinear(u.uColor.value as THREE.Vector3, cfg.color ?? DEFAULT_COLOR);\n setLinear(u.uColor2.value as THREE.Vector3, cfg.color2 ?? cfg.color ?? DEFAULT_COLOR);\n }\n\n /** Push the per-frame frame basis (the camera can move, so this runs every frame). */\n frame(f: ParticleFrame, pixelRatio: number): void {\n const u = this.material.uniforms;\n (u.uCenter.value as THREE.Vector3).copy(f.center);\n (u.uRight.value as THREE.Vector3).copy(f.right);\n (u.uUp.value as THREE.Vector3).copy(f.up);\n u.uPixelRatio.value = pixelRatio;\n }\n\n /** Bind the OWNING wave's shape: mirror its shape #defines + shape uniforms + world matrix so the\n * dust rides the SAME deform as the ribbon. Recompiles the point program only when the define set\n * changes. Called from refresh() each frame with the wave's live state. */\n configure(shape: {\n defines: Record<string, string>;\n uniforms: Record<string, THREE.IUniform>;\n matrixWorld: THREE.Matrix4;\n speed: number;\n seed: number;\n }): void {\n const want = shape.defines;\n const cur = (this.material.defines ?? {}) as Record<string, string>;\n if (Object.keys(want).sort().join(\",\") !== Object.keys(cur).sort().join(\",\")) {\n this.material.defines = { ...want };\n this.material.needsUpdate = true; // define set changed → recompile the point program\n }\n const u = this.material.uniforms;\n for (const name of SHAPE_UNIFORMS) {\n const src = shape.uniforms[name];\n if (!src || u[name] === undefined) continue;\n const dst = u[name].value;\n if (typeof src.value === \"number\") u[name].value = src.value;\n else if (dst && typeof (dst as { copy?: unknown }).copy === \"function\") {\n (dst as THREE.Vector3).copy(src.value as THREE.Vector3);\n }\n }\n (u.uShedModel.value as THREE.Matrix4).copy(shape.matrixWorld);\n u.uShedSpeed.value = shape.speed;\n u.uShedSeed.value = shape.seed;\n }\n\n /** Advance the field to scene time `t` (= the same `t` the waves get). */\n setTime(t: number): void {\n this.material.uniforms.uTime.value = t;\n }\n\n dispose(): void {\n this.geometry.dispose();\n this.material.dispose();\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"particleField.js","names":[],"sources":["../../src/renderer/particleField.ts"],"sourcesContent":["import * as THREE from \"three\";\nimport type { ParticlesConfig } from \"../config/model\";\nimport { RIPPLE_SLOTS } from \"./interactionGates\";\nimport { particleFragmentShader, particleVertexShader } from \"./shaders\";\n\n/**\n * A WAVE's additive particle / dust field: a single {@link THREE.Points} whose every particle is placed\n * and animated ENTIRELY in the vertex shader from `uTime` + baked per-particle attributes, so the field\n * is deterministic — the same `(count, seed, edgeBias, bias)` yields byte-identical buffers, and all\n * motion is a pure function of the scene time `t` (so timeOffset scrub / loopSeconds / paused reproduce).\n *\n * One field belongs to ONE wave (created/disposed alongside it, like {@link WavePalette}). Every particle\n * spawns on that wave's DEFORMED surface / edge (via the shared waveShape chunk, riding the exact deform\n * the ribbon uses) and drifts outward from the wave centre. Byte-identical when off: absent\n * `wave.particles` ⇒ the renderer never creates a field.\n */\nexport interface ParticleFrame {\n /** The owning wave's world-space centre — drift radiates from here. */\n center: THREE.Vector3;\n /** Screen-right / screen-up unit vectors (world space) for screen-relative motion. */\n right: THREE.Vector3;\n up: THREE.Vector3;\n}\n\n/** Deterministic PRNG (mulberry32): a pure function of the seed, so a `(count, seed)` layout\n * reproduces exactly. NEVER Math.random — that would desync timeOffset scrub / loop / paused. */\nfunction mulberry32(seed: number): () => number {\n let a = seed >>> 0;\n return () => {\n a |= 0;\n a = (a + 0x6d2b79f5) | 0;\n let t = Math.imul(a ^ (a >>> 15), 1 | a);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n// sRGB hex → linear RGB. Replicates WaveRenderer.hexToLinearVec3 locally to avoid a circular import\n// (the renderer imports this module): three's ColorManagement linearizes on parse, so read .r/.g/.b\n// directly — a second convertSRGBToLinear() would double-linearize.\nconst HEX_SCRATCH = new THREE.Color();\nexport function setLinear(target: THREE.Vector3, hex: string): void {\n const c = HEX_SCRATCH.set(hex);\n target.set(c.r, c.g, c.b);\n}\n\nexport const DEFAULT_COLOR = \"#ffcf8a\"; // warm gold\n\n/**\n * Edge of the square canvas a {@link ParticlesConfig.spriteUrl} is rasterized into. SVG has no\n * intrinsic pixel size, so something has to choose one — this is it.\n *\n * 256² RGBA is ~256 KB (~350 KB with mipmaps), which is LESS than the per-particle attribute\n * buffers a 20k field already uploads (~800 KB): one texture serves every particle in the field, so\n * sprite artwork is not what makes a dust field expensive. 256 also covers the largest sprite the\n * hardware will draw — gl.ALIASED_POINT_SIZE_RANGE tops out around 511 px, and `size` clamps to 200\n * before the pixel-ratio multiply.\n */\nconst SPRITE_PX = 256;\n\n/** Sprite-shape name → the int the fragment shader branches on (see particleFragmentShader). */\nexport const SHAPE_INDEX: Record<string, number> = {\n glitter: 0,\n soft: 1,\n ring: 2,\n star: 3,\n streak: 4,\n};\n\n/** The owning wave's shape uniforms mirrored onto the particle material so the dust rides the same\n * deform as the ribbon. Names match the wave material's uniforms 1:1 (copied by value in configure). */\nconst SHAPE_UNIFORMS = [\n \"uDispFreqX\",\n \"uDispFreqZ\",\n \"uDispAmount\",\n \"uDetailFreq\",\n \"uDetailAmount\",\n \"uTwFreqX\",\n \"uTwFreqY\",\n \"uTwFreqZ\",\n \"uTwPowX\",\n \"uTwPowY\",\n \"uTwPowZ\",\n \"uHelixTurns\",\n \"uHelixRadius\",\n \"uHelixRoll\",\n \"uHelixPhase\",\n \"uRadialAmount\",\n \"uRadialArc\",\n \"uRadialSpread\",\n \"uRadialRadius\",\n \"uRadialCenter\",\n] as const;\n\n/** The owning wave's POINTER-FIELD uniforms, mirrored the same way so the dust reads the exact\n * cursor state the ribbon does (see pointerFieldChunk). Only uploaded under POINTER_FX — configure()\n * copies them regardless, which is a handful of scalars. The ripple entries are ARRAYS: those are\n * shared by reference (the wave owns them and applyPointerField mutates them in place), so a click\n * costs no per-frame copy. Mirroring rather than a second CPU write also keeps capture determinism\n * for free — applyInteractionRest zeroes the wave's uPointerActive / uRippleAmp, and the dust\n * inherits that rest state on the same frame (updateSceneFx runs after applyInteraction). */\nconst POINTER_UNIFORMS = [\n \"uPointer\",\n \"uPointerActive\",\n \"uPointerRadius\",\n \"uPointerAspect\",\n \"uPointerAgitate\",\n \"uPointerPush\",\n \"uPointerWake\",\n \"uPointerVel\",\n \"uShapeFlow\",\n \"uRippleOrigin\",\n \"uRippleAge\",\n \"uRippleAmp\",\n \"uPointerRipple\",\n] as const;\n\n/** Build the seeded per-particle attribute buffers. Pure function of `(count, seed, edgeBias, bias)` —\n * exported so a unit test can assert reproducibility without a GPU. */\nexport function buildParticleAttributes(\n count: number,\n seed: number,\n edgeBias = 1,\n bias = 0,\n): {\n position: Float32Array;\n aSeed: Float32Array;\n aRnd: Float32Array;\n aUv: Float32Array;\n} {\n const rand = mulberry32(seed >>> 0 || 1);\n const position = new Float32Array(count * 3); // dummy — the vertex shader computes real positions\n const aSeed = new Float32Array(count);\n const aRnd = new Float32Array(count * 4);\n const aUv = new Float32Array(count * 2);\n for (let i = 0; i < count; i++) {\n aSeed[i] = rand();\n aRnd[i * 4 + 0] = rand();\n aRnd[i * 4 + 1] = rand();\n aRnd[i * 4 + 2] = rand();\n aRnd[i * 4 + 3] = rand();\n }\n // aUv in a SEPARATE pass so adding/changing it never shifts the aSeed/aRnd RNG sequence above. aUv.x\n // = the flank position across the ribbon width; `bias` skews it toward one side (`u^p`, p<1 crowds\n // →1, p>1 crowds →0; p=1 / bias 0 leaves it untouched). aUv.y = WHERE ALONG the ribbon the particle\n // spawns, interpolated by `edgeBias`: 0 → uniform across the whole SURFACE, 1 → crowded to the outer\n // rim/EDGE (`1 − rand²·0.45`, the silk-dissolving-into-glitter look).\n const pexp = bias === 0 ? 1 : Math.exp(-bias * 2);\n const eb = Math.min(Math.max(edgeBias, 0), 1);\n for (let i = 0; i < count; i++) {\n const ux = rand();\n aUv[i * 2 + 0] = bias === 0 ? ux : Math.pow(ux, pexp);\n const e = rand();\n const rim = 1.0 - e * e * 0.45; // outer-rim biased\n aUv[i * 2 + 1] = e + (rim - e) * eb; // mix(surface, edge) by edgeBias\n }\n return { position, aSeed, aRnd, aUv };\n}\n\n/**\n * Rasterize `url` into a square {@link SPRITE_PX} texture.\n *\n * Deliberately the BACKGROUND-image pattern (load -> apply -> ask for a redraw) rather than a\n * fire-and-forget TextureLoader: a thumbnail or poster snapshotted while the texture was still in\n * flight would capture blank dust — the same class of bug that made image-driven preset thumbnails\n * render empty.\n *\n * Shared by both backends' particle fields, so the CORS rule, the letterboxing and the mipmap\n * settings have one home.\n */\nexport function loadSpriteTexture(\n url: string,\n onLoaded: (tex: THREE.CanvasTexture) => void,\n onFailed: () => void,\n): void {\n const img = new Image();\n img.decoding = \"async\";\n // data:/blob: are same-origin already; anything else must be CORS-clean or the canvas taints\n // and readback (thumbnails, posters, captureImage) throws.\n if (!url.startsWith(\"data:\") && !url.startsWith(\"blob:\")) img.crossOrigin = \"anonymous\";\n img.addEventListener(\n \"load\",\n () => {\n const canvas = document.createElement(\"canvas\");\n canvas.width = SPRITE_PX;\n canvas.height = SPRITE_PX;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n // CONTAIN the artwork: a point sprite is always square, so non-square art has to be\n // letterboxed or it draws stretched. An SVG with no intrinsic size reports 0 in some\n // browsers — fall back to filling the square.\n const iw = img.naturalWidth || SPRITE_PX;\n const ih = img.naturalHeight || SPRITE_PX;\n const fit = Math.min(SPRITE_PX / iw, SPRITE_PX / ih);\n const w = iw * fit;\n const h = ih * fit;\n ctx.drawImage(img, (SPRITE_PX - w) / 2, (SPRITE_PX - h) / 2, w, h);\n const tex = new THREE.CanvasTexture(canvas);\n tex.colorSpace = THREE.SRGBColorSpace;\n // Mipmaps matter here in a way they do not for the palette: `sizeJitter` and the birth/death\n // fade draw the SAME texture at wildly different pixel sizes.\n tex.generateMipmaps = true;\n tex.minFilter = THREE.LinearMipmapLinearFilter;\n tex.magFilter = THREE.LinearFilter;\n tex.wrapS = THREE.ClampToEdgeWrapping;\n tex.wrapT = THREE.ClampToEdgeWrapping;\n onLoaded(tex);\n },\n { once: true },\n );\n img.addEventListener(\"error\", onFailed, { once: true });\n img.src = url;\n}\n\nexport class ParticleField {\n readonly points: THREE.Points;\n /** The scene node, named the same on both backends (the TSL field is a Sprite, not Points). */\n get object(): THREE.Object3D {\n return this.points;\n }\n\n private readonly geometry = new THREE.BufferGeometry();\n private readonly material: THREE.ShaderMaterial;\n /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */\n private sig = \"\";\n /** Rasterized user artwork for shape \"sprite\", and the url it came from (so a repeat sync is a\n * no-op). `spriteFailedUrl` latches a broken image so a bad url is not retried every frame. */\n private sprite?: THREE.CanvasTexture;\n private spriteUrl = \"\";\n private spriteFailedUrl = \"\";\n private disposed = false;\n /** Defines this field owns (currently just PARTICLE_SPRITE), merged over the wave's in configure().\n * Set only once a texture is actually bound, so the sampler never compiles without one. */\n private ownDefines: Record<string, string> = {};\n /** Called when a sprite finishes rasterizing, so a paused/settled renderer redraws with it. */\n private readonly onReady?: () => void;\n\n constructor(onReady?: () => void) {\n this.onReady = onReady;\n this.material = new THREE.ShaderMaterial({\n uniforms: {\n uTime: { value: 0 },\n uLoopSeconds: { value: 0 },\n uLife: { value: 6 },\n uPartSpeed: { value: 1 },\n uSize: { value: 2 },\n uSizeJitter: { value: 0 },\n uTwinkle: { value: 0 },\n uPixelRatio: { value: 1 },\n uColor: { value: new THREE.Vector3(1, 0.81, 0.54) },\n uColor2: { value: new THREE.Vector3(1, 0.81, 0.54) },\n uCenter: { value: new THREE.Vector3() },\n uRight: { value: new THREE.Vector3(1, 0, 0) },\n uUp: { value: new THREE.Vector3(0, 1, 0) },\n uDrift: { value: 0 },\n uRise: { value: 0 },\n uSwirl: { value: 0 },\n uWander: { value: 0 },\n uShape: { value: 0 },\n // The owning wave's shape uniforms + world matrix, mirrored in configure() so the dust rides\n // the same deform as the ribbon. The nested HELIX/RADIAL uniforms are only declared (and\n // uploaded) when the matching #define is set — the byte-identity precedent from the wave material.\n uDispFreqX: { value: 0 },\n uDispFreqZ: { value: 0 },\n uDispAmount: { value: 0 },\n uDetailFreq: { value: 0 },\n uDetailAmount: { value: 0 },\n uTwFreqX: { value: 0 },\n uTwFreqY: { value: 0 },\n uTwFreqZ: { value: 0 },\n uTwPowX: { value: 0 },\n uTwPowY: { value: 0 },\n uTwPowZ: { value: 0 },\n uHelixTurns: { value: 0 },\n uHelixRadius: { value: 0 },\n uHelixRoll: { value: 0 },\n uHelixPhase: { value: 0 },\n uRadialAmount: { value: 0 },\n uRadialArc: { value: 0 },\n uRadialSpread: { value: 0 },\n uRadialRadius: { value: 0 },\n uRadialCenter: { value: 0 },\n uShedModel: { value: new THREE.Matrix4() },\n uShedSpeed: { value: 0 },\n uShedSeed: { value: 0 },\n // Pointer field, mirrored from the owning wave in configure() (read only under POINTER_FX).\n // The ripple arrays are sized to RIPPLE_SLOTS so the material is valid before the first\n // configure(); configure() then swaps in the wave's own arrays by reference.\n uPointer: { value: new THREE.Vector2() },\n uPointerActive: { value: 0 },\n uPointerRadius: { value: 0.6 },\n uPointerAspect: { value: 1 },\n uPointerAgitate: { value: 0 },\n uPointerPush: { value: 0 },\n uPointerWake: { value: 0 },\n uPointerVel: { value: new THREE.Vector2() },\n uShapeFlow: { value: 0 },\n uRippleOrigin: { value: Array.from({ length: RIPPLE_SLOTS }, () => new THREE.Vector2()) },\n uRippleAge: { value: Array.from({ length: RIPPLE_SLOTS }, () => 0) },\n uRippleAmp: { value: Array.from({ length: RIPPLE_SLOTS }, () => 0) },\n uPointerRipple: { value: 0 },\n uPartShove: { value: 1 },\n // User artwork (read only under PARTICLE_SPRITE, which is set only once this is non-null).\n uSprite: { value: null as THREE.Texture | null },\n },\n vertexShader: particleVertexShader,\n fragmentShader: particleFragmentShader,\n transparent: true,\n depthTest: false, // always composite OVER the waves...\n depthWrite: false, // ...and never occlude anything (additive glints)\n blending: THREE.AdditiveBlending,\n });\n this.points = new THREE.Points(this.geometry, this.material);\n this.points.frustumCulled = false; // positions are shader-computed; the base geometry is dummy\n this.points.renderOrder = 10; // after the waves (0..5)\n }\n\n /** Reconcile to `cfg`: rebuild the seeded buffers if the layout signature changed, then push the\n * frame-independent uniforms. `loopSeconds` is scene-level (passed in). Called from refresh(). */\n sync(cfg: ParticlesConfig, loopSeconds: number): void {\n const count = Math.max(0, Math.floor(cfg.count));\n const edgeBias = cfg.edgeBias ?? 1;\n const bias = cfg.bias ?? 0;\n const sig = `${count}|${cfg.seed}|${edgeBias}|${bias}`;\n if (sig !== this.sig) {\n this.sig = sig;\n const { position, aSeed, aRnd, aUv } = buildParticleAttributes(\n count,\n cfg.seed,\n edgeBias,\n bias,\n );\n this.geometry.setAttribute(\"position\", new THREE.BufferAttribute(position, 3));\n this.geometry.setAttribute(\"aSeed\", new THREE.BufferAttribute(aSeed, 1));\n this.geometry.setAttribute(\"aRnd\", new THREE.BufferAttribute(aRnd, 4));\n this.geometry.setAttribute(\"aUv\", new THREE.BufferAttribute(aUv, 2));\n this.geometry.setDrawRange(0, count);\n }\n const u = this.material.uniforms;\n u.uLoopSeconds.value = loopSeconds;\n u.uLife.value = cfg.life ?? 6;\n u.uPartSpeed.value = cfg.speed ?? 1;\n u.uSize.value = cfg.size;\n u.uSizeJitter.value = cfg.sizeJitter ?? 0;\n u.uTwinkle.value = cfg.twinkle ?? 0;\n u.uDrift.value = cfg.drift ?? 0;\n u.uRise.value = cfg.rise ?? 0;\n u.uSwirl.value = cfg.swirl ?? 0;\n u.uWander.value = cfg.wander ?? 0;\n u.uShape.value = SHAPE_INDEX[cfg.shape ?? \"glitter\"] ?? 0;\n u.uPartShove.value = cfg.pointerShove ?? 1;\n this.syncSprite(cfg.shape === \"sprite\" ? (cfg.spriteUrl ?? \"\") : \"\");\n setLinear(u.uColor.value as THREE.Vector3, cfg.color ?? DEFAULT_COLOR);\n setLinear(u.uColor2.value as THREE.Vector3, cfg.color2 ?? cfg.color ?? DEFAULT_COLOR);\n }\n\n /** Push the per-frame frame basis (the camera can move, so this runs every frame). */\n frame(f: ParticleFrame, pixelRatio: number): void {\n const u = this.material.uniforms;\n (u.uCenter.value as THREE.Vector3).copy(f.center);\n (u.uRight.value as THREE.Vector3).copy(f.right);\n (u.uUp.value as THREE.Vector3).copy(f.up);\n u.uPixelRatio.value = pixelRatio;\n }\n\n /** Bind the OWNING wave's shape AND cursor state: mirror its #defines + shape uniforms + pointer\n * uniforms + world matrix, so the dust rides the same deform as the ribbon and reacts to the same\n * pointer field. Recompiles the point program only when the define set changes — which is why the\n * defines must come from CONFIG only (shapeDefines), never from live input. Called from refresh()\n * each frame with the wave's live state. */\n configure(shape: {\n defines: Record<string, string>;\n uniforms: Record<string, THREE.IUniform>;\n matrixWorld: THREE.Matrix4;\n speed: number;\n seed: number;\n }): void {\n const want = { ...shape.defines, ...this.ownDefines };\n const cur = (this.material.defines ?? {}) as Record<string, string>;\n if (Object.keys(want).sort().join(\",\") !== Object.keys(cur).sort().join(\",\")) {\n this.material.defines = { ...want };\n this.material.needsUpdate = true; // define set changed → recompile the point program\n }\n const u = this.material.uniforms;\n for (const name of SHAPE_UNIFORMS) this.mirror(shape.uniforms, name);\n for (const name of POINTER_UNIFORMS) this.mirror(shape.uniforms, name);\n (u.uShedModel.value as THREE.Matrix4).copy(shape.matrixWorld);\n u.uShedSpeed.value = shape.speed;\n u.uShedSeed.value = shape.seed;\n }\n\n /** Reconcile the sprite texture to `url` (\"\" = none). Cheap and idempotent: a repeat call with the\n * same url does nothing, and a url that already failed is never retried. */\n private syncSprite(url: string): void {\n if (url === this.spriteUrl) return;\n this.spriteUrl = url;\n this.clearSprite();\n if (url && url !== this.spriteFailedUrl) this.loadSprite(url);\n }\n\n /** Drop the current sprite and fall back to the procedural shapes (which is what `uShape` still\n * holds, so a field mid-load or with a broken image draws \"glitter\" rather than nothing). */\n private clearSprite(): void {\n if (!this.sprite) return;\n this.sprite.dispose();\n this.sprite = undefined;\n this.material.uniforms.uSprite.value = null;\n if (this.ownDefines.PARTICLE_SPRITE !== undefined) {\n this.ownDefines = {};\n this.material.needsUpdate = true; // configure() will also notice, but a paused field may not tick\n }\n }\n\n /**\n * Rasterize `url` into a square {@link SPRITE_PX} texture and bind it.\n *\n * Deliberately the BACKGROUND-image pattern (load → apply → ask for a redraw) rather than\n * loadPaletteImage's fire-and-forget TextureLoader: a thumbnail or poster snapshotted while the\n * texture was still in flight would capture blank dust — the same class of bug that made\n * image-driven preset thumbnails render empty.\n */\n private loadSprite(url: string): void {\n loadSpriteTexture(\n url,\n (tex) => {\n // The config may have moved on (or the field been disposed) while this was decoding.\n if (this.disposed || this.spriteUrl !== url) return;\n this.sprite = tex;\n this.material.uniforms.uSprite.value = tex;\n this.ownDefines = { PARTICLE_SPRITE: \"\" };\n this.material.needsUpdate = true; // sampler appears -> recompile the point program\n this.onReady?.(); // a paused / settled renderer would otherwise never draw it\n },\n () => {\n // Latch the failure so a broken url is not re-requested on every sync.\n this.spriteFailedUrl = url;\n },\n );\n }\n\n /** Copy one uniform across from the owning wave: numbers by value, vectors/matrices in place, and\n * arrays (the ripple slots) by reference — the wave owns those and mutates them in place. */\n private mirror(src: Record<string, THREE.IUniform>, name: string): void {\n const from = src[name];\n const to = this.material.uniforms[name];\n if (!from || !to) return;\n const dst = to.value;\n if (typeof from.value === \"number\" || Array.isArray(from.value)) to.value = from.value;\n else if (dst && typeof (dst as { copy?: unknown }).copy === \"function\") {\n (dst as THREE.Vector3).copy(from.value as THREE.Vector3);\n }\n }\n\n /** Advance the field to scene time `t` (= the same `t` the waves get). */\n setTime(t: number): void {\n this.material.uniforms.uTime.value = t;\n }\n\n dispose(): void {\n this.disposed = true;\n this.sprite?.dispose();\n this.geometry.dispose();\n this.material.dispose();\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,141 @@
1
+ import { SHAPE_INDEX, buildParticleAttributes, loadSpriteTexture, setLinear } from "./particleField.js";
2
+ import { makeParticleUniforms } from "./tsl/particleUniforms.js";
3
+ import { buildParticleMaterial } from "./tsl/particleMaterial.js";
4
+ import * as THREE from "three";
5
+ //#region src/renderer/particleFieldGPU.ts
6
+ /**
7
+ * The particle field on the TSL backend.
8
+ *
9
+ * Same public surface as {@link ParticleField} — `object`, `sync`, `frame`, `configure`, `dispose`
10
+ * — and the same seeded buffers, so a field is byte-identical between backends before it ever
11
+ * reaches a shader. Two things differ, both forced by WebGPU:
12
+ *
13
+ * - The field is a `THREE.Sprite` with `count`, not a `THREE.Points`. WebGPU point primitives are
14
+ * fixed at one pixel, so `gl_PointSize` has no equivalent; three's PointsNodeMaterial honours a
15
+ * size only on a Sprite, drawing instanced quads.
16
+ * - There is no uniform MIRRORING. The GLSL field copies the owning wave's shape and pointer
17
+ * uniforms across to its own material every frame; here the graph reads the wave's registry
18
+ * directly, so the dust and the ribbon are literally driven by the same nodes.
19
+ */
20
+ var ParticleFieldGPU = class {
21
+ host;
22
+ onReady;
23
+ /** The scene object. A Sprite, not Points — see the class note. */
24
+ object = new THREE.Sprite();
25
+ uniforms = makeParticleUniforms();
26
+ material;
27
+ /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */
28
+ sig = "";
29
+ attrs;
30
+ count = 0;
31
+ sprite;
32
+ spriteUrl = "";
33
+ spriteFailedUrl = "";
34
+ disposed = false;
35
+ /** The variant the current material was built for; a change rebuilds the graph. */
36
+ variant = "";
37
+ constructor(host, onReady) {
38
+ this.host = host;
39
+ this.onReady = onReady;
40
+ this.object.frustumCulled = false;
41
+ this.object.renderOrder = 10;
42
+ this.object.count = 0;
43
+ this.object.matrixAutoUpdate = false;
44
+ }
45
+ /** Reconcile to `cfg`, rebuilding the seeded buffers only when the layout signature changed. */
46
+ sync(cfg, loopSeconds) {
47
+ const count = Math.max(0, Math.floor(cfg.count));
48
+ const edgeBias = cfg.edgeBias ?? 1;
49
+ const bias = cfg.bias ?? 0;
50
+ const sig = `${count}|${cfg.seed}|${edgeBias}|${bias}`;
51
+ if (sig !== this.sig) {
52
+ this.sig = sig;
53
+ const { aSeed, aRnd, aUv } = buildParticleAttributes(count, cfg.seed, edgeBias, bias);
54
+ this.attrs = {
55
+ aSeed,
56
+ aRnd,
57
+ aUv
58
+ };
59
+ this.count = count;
60
+ this.object.count = count;
61
+ this.variant = "";
62
+ }
63
+ const u = this.uniforms;
64
+ u.uLoopSeconds.value = loopSeconds;
65
+ u.uLife.value = cfg.life ?? 6;
66
+ u.uPartSpeed.value = cfg.speed ?? 1;
67
+ u.uSize.value = cfg.size;
68
+ u.uSizeJitter.value = cfg.sizeJitter ?? 0;
69
+ u.uTwinkle.value = cfg.twinkle ?? 0;
70
+ u.uDrift.value = cfg.drift ?? 0;
71
+ u.uRise.value = cfg.rise ?? 0;
72
+ u.uSwirl.value = cfg.swirl ?? 0;
73
+ u.uWander.value = cfg.wander ?? 0;
74
+ u.uShape.value = SHAPE_INDEX[cfg.shape ?? "glitter"] ?? 0;
75
+ u.uPartShove.value = cfg.pointerShove ?? 1;
76
+ setLinear(u.uColor.value, cfg.color ?? "#ffcf8a");
77
+ setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? "#ffcf8a");
78
+ this.syncSprite(cfg.shape === "sprite" ? cfg.spriteUrl ?? "" : "");
79
+ this.ensureMaterial();
80
+ }
81
+ /** Push the per-frame frame basis (the camera can move, so this runs every frame). */
82
+ frame(f, _pixelRatio) {
83
+ this.uniforms.uCenter.value.copy(f.center);
84
+ this.uniforms.uRight.value.copy(f.right);
85
+ this.uniforms.uUp.value.copy(f.up);
86
+ }
87
+ /** Bind the owning wave's world matrix and shed cadence. The shape and pointer uniforms need no
88
+ * mirroring on this backend — the graph reads the wave's registry directly. */
89
+ configure(shape) {
90
+ this.uniforms.uShedModel.value.copy(shape.matrixWorld);
91
+ this.uniforms.uShedSpeed.value = shape.speed;
92
+ this.uniforms.uShedSeed.value = shape.seed;
93
+ this.ensureMaterial();
94
+ }
95
+ /** Build (or rebuild) the node graph when the variant it was compiled for no longer applies. */
96
+ ensureMaterial() {
97
+ if (!this.attrs || this.count === 0) return;
98
+ const flags = {
99
+ ...this.host.flags,
100
+ sprite: !!this.sprite
101
+ };
102
+ const key = `${this.sig}|${JSON.stringify(flags)}`;
103
+ if (key === this.variant) return;
104
+ this.variant = key;
105
+ this.material?.dispose();
106
+ this.material = buildParticleMaterial(this.host.uniforms, this.uniforms, this.attrs, flags, this.sprite ?? null);
107
+ this.object.material = this.material;
108
+ }
109
+ syncSprite(url) {
110
+ if (url === this.spriteUrl) return;
111
+ this.spriteUrl = url;
112
+ if (this.sprite) {
113
+ this.sprite.dispose();
114
+ this.sprite = void 0;
115
+ this.variant = "";
116
+ }
117
+ if (!url || url === this.spriteFailedUrl) return;
118
+ loadSpriteTexture(url, (tex) => {
119
+ if (this.disposed || this.spriteUrl !== url) return;
120
+ this.sprite = tex;
121
+ this.variant = "";
122
+ this.ensureMaterial();
123
+ this.onReady?.();
124
+ }, () => {
125
+ this.spriteFailedUrl = url;
126
+ });
127
+ }
128
+ setTime(t) {
129
+ this.uniforms.uTime.value = t;
130
+ }
131
+ dispose() {
132
+ this.disposed = true;
133
+ this.material?.dispose();
134
+ this.sprite?.dispose();
135
+ this.object.geometry.dispose();
136
+ }
137
+ };
138
+ //#endregion
139
+ export { ParticleFieldGPU };
140
+
141
+ //# sourceMappingURL=particleFieldGPU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"particleFieldGPU.js","names":[],"sources":["../../src/renderer/particleFieldGPU.ts"],"sourcesContent":["/**\n * The particle field on the TSL backend.\n *\n * Same public surface as {@link ParticleField} — `object`, `sync`, `frame`, `configure`, `dispose`\n * — and the same seeded buffers, so a field is byte-identical between backends before it ever\n * reaches a shader. Two things differ, both forced by WebGPU:\n *\n * - The field is a `THREE.Sprite` with `count`, not a `THREE.Points`. WebGPU point primitives are\n * fixed at one pixel, so `gl_PointSize` has no equivalent; three's PointsNodeMaterial honours a\n * size only on a Sprite, drawing instanced quads.\n * - There is no uniform MIRRORING. The GLSL field copies the owning wave's shape and pointer\n * uniforms across to its own material every frame; here the graph reads the wave's registry\n * directly, so the dust and the ribbon are literally driven by the same nodes.\n */\nimport * as THREE from \"three\";\nimport type { PointsNodeMaterial } from \"three/webgpu\";\nimport type { ParticlesConfig } from \"../config/model\";\nimport {\n buildParticleAttributes,\n loadSpriteTexture,\n setLinear,\n DEFAULT_COLOR,\n SHAPE_INDEX,\n type ParticleFrame,\n} from \"./particleField\";\nimport { makeParticleUniforms, type ParticleTslUniforms } from \"./tsl/particleUniforms\";\nimport {\n buildParticleMaterial,\n type ParticleMaterialFlags,\n type ParticleAttributeArrays,\n} from \"./tsl/particleMaterial\";\nimport type { WaveTslUniforms } from \"./tsl/uniforms\";\n\n/** What the owning wave contributes: its uniform registry and the shape variant its graph uses. */\nexport interface ParticleHost {\n uniforms: WaveTslUniforms;\n flags: Omit<ParticleMaterialFlags, \"sprite\">;\n}\n\nexport class ParticleFieldGPU {\n /** The scene object. A Sprite, not Points — see the class note. */\n readonly object = new THREE.Sprite();\n private readonly uniforms: ParticleTslUniforms = makeParticleUniforms();\n private material?: PointsNodeMaterial;\n /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */\n private sig = \"\";\n private attrs?: ParticleAttributeArrays;\n private count = 0;\n private sprite?: THREE.CanvasTexture;\n private spriteUrl = \"\";\n private spriteFailedUrl = \"\";\n private disposed = false;\n /** The variant the current material was built for; a change rebuilds the graph. */\n private variant = \"\";\n\n constructor(\n private readonly host: ParticleHost,\n private readonly onReady?: () => void,\n ) {\n this.object.frustumCulled = false; // positions are shader-computed; the quad is a dummy\n this.object.renderOrder = 10; // after the waves (0..5)\n this.object.count = 0;\n // The sprite's own transform must stay identity: the emitter computes WORLD positions, exactly\n // as the GLSL does, and PointsNodeMaterial feeds positionNode through modelViewMatrix.\n this.object.matrixAutoUpdate = false;\n }\n\n /** Reconcile to `cfg`, rebuilding the seeded buffers only when the layout signature changed. */\n sync(cfg: ParticlesConfig, loopSeconds: number): void {\n const count = Math.max(0, Math.floor(cfg.count));\n const edgeBias = cfg.edgeBias ?? 1;\n const bias = cfg.bias ?? 0;\n const sig = `${count}|${cfg.seed}|${edgeBias}|${bias}`;\n if (sig !== this.sig) {\n this.sig = sig;\n const { aSeed, aRnd, aUv } = buildParticleAttributes(count, cfg.seed, edgeBias, bias);\n this.attrs = { aSeed, aRnd, aUv };\n this.count = count;\n this.object.count = count;\n this.variant = \"\"; // the attribute nodes are baked into the graph, so it has to be rebuilt\n }\n\n const u = this.uniforms;\n u.uLoopSeconds.value = loopSeconds;\n u.uLife.value = cfg.life ?? 6;\n u.uPartSpeed.value = cfg.speed ?? 1;\n u.uSize.value = cfg.size;\n u.uSizeJitter.value = cfg.sizeJitter ?? 0;\n u.uTwinkle.value = cfg.twinkle ?? 0;\n u.uDrift.value = cfg.drift ?? 0;\n u.uRise.value = cfg.rise ?? 0;\n u.uSwirl.value = cfg.swirl ?? 0;\n u.uWander.value = cfg.wander ?? 0;\n u.uShape.value = SHAPE_INDEX[cfg.shape ?? \"glitter\"] ?? 0;\n u.uPartShove.value = cfg.pointerShove ?? 1;\n setLinear(u.uColor.value, cfg.color ?? DEFAULT_COLOR);\n setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? DEFAULT_COLOR);\n this.syncSprite(cfg.shape === \"sprite\" ? (cfg.spriteUrl ?? \"\") : \"\");\n this.ensureMaterial();\n }\n\n /** Push the per-frame frame basis (the camera can move, so this runs every frame). */\n frame(f: ParticleFrame, _pixelRatio: number): void {\n // _pixelRatio is deliberately unused: three's PointsNodeMaterial multiplies sizeNode by the\n // device pixel ratio itself, so applying it here too would square the scaling.\n this.uniforms.uCenter.value.copy(f.center);\n this.uniforms.uRight.value.copy(f.right);\n this.uniforms.uUp.value.copy(f.up);\n }\n\n /** Bind the owning wave's world matrix and shed cadence. The shape and pointer uniforms need no\n * mirroring on this backend — the graph reads the wave's registry directly. */\n configure(shape: {\n /** Present for signature parity with the GLSL field; unused here — see the class note. */\n defines?: Record<string, string>;\n /** Ditto: this backend reads the wave's registry directly rather than copying from it. */\n uniforms?: Record<string, { value: unknown }>;\n matrixWorld: THREE.Matrix4;\n speed: number;\n seed: number;\n }): void {\n this.uniforms.uShedModel.value.copy(shape.matrixWorld);\n this.uniforms.uShedSpeed.value = shape.speed;\n this.uniforms.uShedSeed.value = shape.seed;\n this.ensureMaterial();\n }\n\n /** Build (or rebuild) the node graph when the variant it was compiled for no longer applies. */\n private ensureMaterial(): void {\n if (!this.attrs || this.count === 0) return;\n const flags: ParticleMaterialFlags = { ...this.host.flags, sprite: !!this.sprite };\n const key = `${this.sig}|${JSON.stringify(flags)}`;\n if (key === this.variant) return;\n this.variant = key;\n this.material?.dispose();\n this.material = buildParticleMaterial(\n this.host.uniforms,\n this.uniforms,\n this.attrs,\n flags,\n this.sprite ?? null,\n );\n this.object.material = this.material as unknown as THREE.SpriteMaterial;\n }\n\n private syncSprite(url: string): void {\n if (url === this.spriteUrl) return;\n this.spriteUrl = url;\n if (this.sprite) {\n this.sprite.dispose();\n this.sprite = undefined;\n this.variant = \"\"; // the sampler disappears from the graph\n }\n if (!url || url === this.spriteFailedUrl) return;\n loadSpriteTexture(\n url,\n (tex) => {\n if (this.disposed || this.spriteUrl !== url) return;\n this.sprite = tex;\n this.variant = \"\"; // the sampler appears in the graph\n this.ensureMaterial();\n this.onReady?.(); // a paused / settled renderer would otherwise never draw it\n },\n () => {\n this.spriteFailedUrl = url;\n },\n );\n }\n\n setTime(t: number): void {\n this.uniforms.uTime.value = t;\n }\n\n dispose(): void {\n this.disposed = true;\n this.material?.dispose();\n this.sprite?.dispose();\n this.object.geometry.dispose();\n }\n}\n"],"mappings":"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"}
@@ -258,6 +258,102 @@ WaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){
258
258
  return s;
259
259
  }
260
260
  `;
261
+ const pointerFieldChunk = `
262
+ uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
263
+ uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
264
+ uniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)
265
+ uniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)
266
+ uniform float uPointerAgitate;
267
+ uniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)
268
+ uniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)
269
+ uniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)
270
+ // Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the
271
+ // ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).
272
+ uniform float uShapeFlow;
273
+ #ifdef POINTER_RIPPLES
274
+ uniform vec2 uRippleOrigin[4]; // NDC
275
+ uniform float uRippleAge[4]; // seconds since spawn (CPU-computed)
276
+ uniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)
277
+ uniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)
278
+ const float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward
279
+ const float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)
280
+ const float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)
281
+ const float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame
282
+ #endif
283
+
284
+ // fall = screen falloff × presence (the wave's vPointerFall, which both fragment themes consume);
285
+ // disp = the signed displacement along the surface's own up-axis, which the CALLER applies (the wave
286
+ // in its local space, the dust through the wave's world matrix).
287
+ struct PointerHit { float fall; float disp; };
288
+
289
+ // Sample the field for ONE point. ndc is that point's screen position; mvp the clip transform of the
290
+ // space rotA/rotB/rotC and churnPos live in (the owning wave's local space); t / loopOff the caller's
291
+ // linear / orbit time — only the one selected by LOOP_MOTION is read.
292
+ PointerHit pointerField(vec2 ndc, mat4 mvp, mat4 rotA, mat4 rotB, mat4 rotC, vec3 churnPos,
293
+ float t, vec2 loopOff){
294
+ // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.
295
+ vec2 dp = (ndc - uPointer) * vec2(uPointerAspect, 1.0);
296
+ // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the
297
+ // ribbon and stays tight across it — the "flows with the material" feel, per-vertex (so it follows
298
+ // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)
299
+ // carried through the SAME twist as the surface. The camera is orthographic (affine, w=1), so the
300
+ // axis's screen image is the linear map of the DIRECTION (w=0): one mat·dir, no second
301
+ // point-projection and no perspective divide. (A true per-pixel uv would need GPU picking — the
302
+ // visible surface is shader-displaced, so a CPU raycast of the base geometry misses.)
303
+ if (uShapeFlow > 0.0) {
304
+ vec3 tangentLocal = (((vec4(1.0, 0.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;
305
+ vec2 tang = (mvp * vec4(tangentLocal, 0.0)).xy * vec2(uPointerAspect, 1.0);
306
+ float tl = length(tang);
307
+ if (tl > 1.0e-6) {
308
+ tang /= tl;
309
+ vec2 nrm = vec2(-tang.y, tang.x);
310
+ dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length
311
+ }
312
+ }
313
+ float fall = smoothstep(uPointerRadius, 0.0, length(dp)) * uPointerActive;
314
+ // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which
315
+ // would force restructuring the shared path). Loop-safe under both time variants.
316
+ #ifdef LOOP_MOTION
317
+ float disp = uPointerAgitate * fall
318
+ * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0, churnPos.z * uDispFreqZ * 3.0) + loopOff * 4.0);
319
+ #else
320
+ float disp = uPointerAgitate * fall
321
+ * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0 + t * 4.0, churnPos.z * uDispFreqZ * 3.0));
322
+ #endif
323
+ // Membrane push/pull: a smooth dome (fall is the falloff) that swells toward you (+ repel) or dents
324
+ // away (− attract) at the cursor, riding along with the sprung field.
325
+ disp += uPointerPush * fall;
326
+ // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points
327
+ // from cursor to vertex; "behind" is how far the vertex sits opposite the velocity (0 ahead → 1 a
328
+ // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.
329
+ vec2 velC = uPointerVel * vec2(uPointerAspect, 1.0);
330
+ float wakeSpeed = length(velC);
331
+ if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {
332
+ float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);
333
+ disp -= uPointerWake * fall * behind * smoothstep(0.05, 0.6, wakeSpeed);
334
+ }
335
+ #ifdef POINTER_RIPPLES
336
+ for (int i = 0; i < 4; i++) {
337
+ if (uRippleAmp[i] > 0.0) {
338
+ float rd = length((ndc - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));
339
+ // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on
340
+ // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),
341
+ // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp
342
+ // envelope fades the whole packet over its lifetime; reach fades it as the crest leaves frame.
343
+ float front = uRippleAge[i] * RIPPLE_WAVE_SPEED;
344
+ float band = rd - front;
345
+ float packet = exp(-band * band / (2.0 * RIPPLE_SIGMA * RIPPLE_SIGMA)) * cos(band * RIPPLE_FREQ);
346
+ float reach = 1.0 - smoothstep(RIPPLE_MAX_R * 0.7, RIPPLE_MAX_R, front);
347
+ disp += uPointerRipple * uRippleAmp[i] * packet * reach;
348
+ }
349
+ }
350
+ #endif
351
+ PointerHit hit;
352
+ hit.fall = fall;
353
+ hit.disp = disp;
354
+ return hit;
355
+ }
356
+ `;
261
357
  const vertexShader = `
262
358
  ${simplex2d}
263
359
 
@@ -291,32 +387,12 @@ varying vec3 vWorldPos;
291
387
  varying vec3 vViewDir;
292
388
  varying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade
293
389
 
294
- // Pointer field (optional, additive). ALL declarations here sit behind POINTER_FX so a wave with
295
- // no interaction config compiles the exact same program (JS-side uniform entries are always present
296
- // see makeUniforms but three only uploads uniforms the compiled program actually declares).
390
+ // Pointer field (optional, additive) the shared chunk, gated so a wave with no interaction config
391
+ // compiles the exact same program. The particle emitter interpolates the SAME chunk, so dust reacts
392
+ // through one implementation of the footprint / falloff / displacement.
297
393
  #ifdef POINTER_FX
298
- uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
299
- uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
300
- uniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)
301
- uniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)
302
- uniform float uPointerAgitate;
303
- uniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)
304
- uniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)
305
- uniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)
306
- // Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the
307
- // ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).
308
- uniform float uShapeFlow;
394
+ ${pointerFieldChunk}
309
395
  varying float vPointerFall; // falloff × presence — consumed by both fragment themes
310
- #ifdef POINTER_RIPPLES
311
- uniform vec2 uRippleOrigin[4]; // NDC
312
- uniform float uRippleAge[4]; // seconds since spawn (CPU-computed)
313
- uniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)
314
- uniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)
315
- const float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward
316
- const float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)
317
- const float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)
318
- const float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame
319
- #endif
320
396
  #endif
321
397
 
322
398
  ${waveShapeChunk}
@@ -351,75 +427,20 @@ void main(){
351
427
  // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space
352
428
  // falloff around the smoothed cursor — a circle at uShapeFlow 0, stretched along the ribbon as it
353
429
  // rises. Everything here is ADDITIVE and fenced, so the shared path above/below is untouched and
354
- // byte-identical when POINTER_FX is off.
430
+ // byte-identical when POINTER_FX is off. The field itself lives in pointerFieldChunk, which the
431
+ // particle emitter also calls — so dust reacts through this exact footprint and falloff.
355
432
  // Shared clip-space transform, computed once and reused for the cursor metric and the ribbon
356
433
  // tangent (the compiler is not guaranteed to CSE the triple product otherwise). Associativity is
357
434
  // unchanged, so preClip is bit-for-bit what the plain P*V*M*v product produced.
358
435
  mat4 mvp = projectionMatrix * viewMatrix * modelMatrix;
359
436
  vec4 preClip = mvp * vec4(pos, 1.0);
360
- // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.
361
- vec2 ndcHere = preClip.xy / max(preClip.w, 1.0e-6);
362
- vec2 dp = (ndcHere - uPointer) * vec2(uPointerAspect, 1.0);
363
- // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the
364
- // ribbon and stays tight across it — the "flows with the material" feel, per-vertex (so it follows
365
- // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)
366
- // carried through the SAME twist as the surface. The camera is orthographic (affine, w=1), so the
367
- // axis's screen image is the linear map of the DIRECTION (w=0): one mat·dir, no second
368
- // point-projection and no perspective divide. (A true per-pixel uv would need GPU picking — the
369
- // visible surface is shader-displaced, so a CPU raycast of the base geometry misses.)
370
- if (uShapeFlow > 0.0) {
371
- vec3 tangentLocal = (((vec4(1.0, 0.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
372
- vec2 tang = (mvp * vec4(tangentLocal, 0.0)).xy * vec2(uPointerAspect, 1.0);
373
- float tl = length(tang);
374
- if (tl > 1.0e-6) {
375
- tang /= tl;
376
- vec2 nrm = vec2(-tang.y, tang.x);
377
- dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length
378
- }
379
- }
380
- float fall = smoothstep(uPointerRadius, 0.0, length(dp));
381
- vPointerFall = fall * uPointerActive;
437
+ PointerHit hit = pointerField(preClip.xy / max(preClip.w, 1.0e-6), mvp,
438
+ ws.rotA, ws.rotB, ws.rotC, pos, t, loopOff);
439
+ vPointerFall = hit.fall;
382
440
  // Displacement axis = local +Y carried through the SAME three twist rotations as pos (row-vector
383
441
  // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.
384
442
  vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
385
- // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which
386
- // would force restructuring the shared path). Loop-safe under both time variants.
387
- #ifdef LOOP_MOTION
388
- float disp = uPointerAgitate * vPointerFall
389
- * simplexNoise(vec2(pos.x * uDispFreqX * 3.0, pos.z * uDispFreqZ * 3.0) + loopOff * 4.0);
390
- #else
391
- float disp = uPointerAgitate * vPointerFall
392
- * simplexNoise(vec2(pos.x * uDispFreqX * 3.0 + t * 4.0, pos.z * uDispFreqZ * 3.0));
393
- #endif
394
- // Membrane push/pull: a smooth dome (vPointerFall is the falloff) that swells toward you (+ repel)
395
- // or dents away (− attract) at the cursor, riding along with the sprung field.
396
- disp += uPointerPush * vPointerFall;
397
- // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points
398
- // from cursor to vertex; "behind" is how far the vertex sits opposite the velocity (0 ahead → 1 a
399
- // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.
400
- vec2 velC = uPointerVel * vec2(uPointerAspect, 1.0);
401
- float wakeSpeed = length(velC);
402
- if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {
403
- float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);
404
- disp -= uPointerWake * vPointerFall * behind * smoothstep(0.05, 0.6, wakeSpeed);
405
- }
406
- #ifdef POINTER_RIPPLES
407
- for (int i = 0; i < 4; i++) {
408
- if (uRippleAmp[i] > 0.0) {
409
- float rd = length((preClip.xy / max(preClip.w, 1.0e-6) - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));
410
- // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on
411
- // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),
412
- // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp
413
- // envelope fades the whole packet over its lifetime; reach fades it as the crest leaves frame.
414
- float front = uRippleAge[i] * RIPPLE_WAVE_SPEED;
415
- float band = rd - front;
416
- float packet = exp(-band * band / (2.0 * RIPPLE_SIGMA * RIPPLE_SIGMA)) * cos(band * RIPPLE_FREQ);
417
- float reach = 1.0 - smoothstep(RIPPLE_MAX_R * 0.7, RIPPLE_MAX_R, front);
418
- disp += uPointerRipple * uRippleAmp[i] * packet * reach;
419
- }
420
- }
421
- #endif
422
- pos += dispAxis * disp;
443
+ pos += dispAxis * hit.disp;
423
444
  #endif
424
445
 
425
446
  // The scale / rotation / position transform lives on the mesh (modelMatrix), so the
@@ -964,6 +985,14 @@ uniform mat4 uShedModel; // the wave's matrixWorld (deformed LOCAL
964
985
  uniform float uShedSpeed, uShedSeed;
965
986
  ${waveShapeChunk}
966
987
 
988
+ // The cursor. Same chunk the ribbon uses, mirrored onto this material in ParticleField.configure(),
989
+ // and behind the same POINTER_FX gate — a wave with no hover field compiles the point program it
990
+ // always did. uPartShove is the one particle-only knob (see the two samples in main).
991
+ #ifdef POINTER_FX
992
+ ${pointerFieldChunk}
993
+ uniform float uPartShove; // how hard the cursor shoves dust that has already drifted free (0 = off)
994
+ #endif
995
+
967
996
  varying float vAlpha;
968
997
  varying vec3 vColor;
969
998
  varying vec2 vDir; // screen-space motion direction (for the streak sprite)
@@ -992,17 +1021,43 @@ void main(){
992
1021
  // Approximate the base hairpin point for this uv (length from uv.y; width centre), then deform it
993
1022
  // exactly as the wave does. Good enough for dust — the fan / displacement dominate.
994
1023
  vec3 base = vec3((aUv.y - 0.5) * 400.0, 0.0, ${(-8).toFixed(1)});
995
- vec3 origin = (uShedModel * vec4(waveShape(base, aUv, ts, loopOff).pos, 1.0)).xyz;
1024
+ WaveShape ws = waveShape(base, aUv, ts, loopOff);
1025
+ vec3 origin = (uShedModel * vec4(ws.pos, 1.0)).xyz;
996
1026
  vec3 outward = normalize(origin - uCenter + vec3(1e-4));
1027
+
1028
+ #ifdef POINTER_FX
1029
+ // WELD (applied below, once the mote's own motion is known). The ribbon displaces its surface by
1030
+ // pointerField() along its own post-twist up-axis; a mote sitting ON that surface has to take the
1031
+ // same ride, or the cursor's dome lifts the silk out from under its own glitter. Sampled at the
1032
+ // SPAWN point and carried through the local→world matrix exactly as the ribbon's own
1033
+ // pos += dispAxis * disp is, so the two land on the same place. Sampling at the spawn point also
1034
+ // leaves outward derived from the UNDISPLACED origin, so a poke never bends the drift direction.
1035
+ mat4 pMvp = projectionMatrix * viewMatrix * uShedModel;
1036
+ vec4 originClip = pMvp * vec4(ws.pos, 1.0);
1037
+ vec3 dispAxis = mat3(uShedModel) * (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
1038
+ PointerHit weld = pointerField(originClip.xy / max(originClip.w, 1.0e-6), pMvp,
1039
+ ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);
1040
+ #endif
1041
+
997
1042
  vec3 p = origin + outward * age * uDrift + (aRnd.xyz - 0.5) * age * uDrift * 0.35;
998
1043
 
999
1044
  // Motion styles, each 0 = off, all riding age so they stay loop-safe (the age wrap is hidden by
1000
1045
  // fade→0 at birth/death). rise = screen-vertical buoyancy (embers up / snow down); swirl = orbit
1001
1046
  // around the wave centre in the screen plane; wander = curl-noise turbulence (fireflies / motes).
1002
1047
  p += uUp * age * uRise;
1048
+ // How far this mote travels away from its birth patch over a WHOLE life — the straight-line terms
1049
+ // plus, under swirl, the arc it sweeps at its own orbit radius. Only the pointer weld reads it
1050
+ // (0 for dust that merely clings to the surface, which is exactly the case age would get wrong),
1051
+ // so it is fenced like everything else the cursor drives.
1052
+ #ifdef POINTER_FX
1053
+ float span = abs(uDrift) * 1.35 + abs(uRise) + uWander;
1054
+ #endif
1003
1055
  if (uSwirl != 0.0) {
1004
1056
  vec3 nrm = cross(uRight, uUp);
1005
1057
  vec3 rel = p - uCenter;
1058
+ #ifdef POINTER_FX
1059
+ span += abs(uSwirl) * TAU * length(rel);
1060
+ #endif
1006
1061
  float rx = dot(rel, uRight), ry = dot(rel, uUp), rz = dot(rel, nrm);
1007
1062
  float a = age * uSwirl * TAU;
1008
1063
  float ca = cos(a), sa = sin(a);
@@ -1014,6 +1069,25 @@ void main(){
1014
1069
  p += (uRight * wan.x + uUp * wan.y) * uWander;
1015
1070
  }
1016
1071
 
1072
+ #ifdef POINTER_FX
1073
+ // How attached to its birth patch this mote still is: 1 while it sits on the surface, 0 once it
1074
+ // has travelled a full life's worth away. Measured from the DISTANCE it actually moved rather than
1075
+ // from age, because dust with no drift / rise / swirl / wander never leaves the surface at all —
1076
+ // an age fade would quietly stop that dust from following the ribbon halfway through its life.
1077
+ float attach = span > 1.0e-4 ? 1.0 - clamp(length(p - origin) / span, 0.0, 1.0) : 1.0;
1078
+ p += dispAxis * (weld.disp * attach);
1079
+ // SHOVE: the exact complement. The same field sampled at the mote's OWN screen position, so the
1080
+ // cursor also pushes dust that has already left the surface — and a click ripple visibly blows
1081
+ // through the cloud instead of stopping dead at the ribbon. Uniform branch (warp-coherent), so
1082
+ // uPartShove 0 costs nothing.
1083
+ if (uPartShove != 0.0) {
1084
+ vec4 pClip = projectionMatrix * viewMatrix * vec4(p, 1.0);
1085
+ PointerHit shove = pointerField(pClip.xy / max(pClip.w, 1.0e-6), pMvp,
1086
+ ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);
1087
+ p += dispAxis * (shove.disp * (1.0 - attach) * uPartShove);
1088
+ }
1089
+ #endif
1090
+
1017
1091
  float tw = 0.5 + 0.5 * sin((age * 9.0 + aSeed) * TAU); // loop-safe flicker (rides age)
1018
1092
  vAlpha = fade * mix(1.0, tw, clamp(uTwinkle, 0.0, 1.0));
1019
1093
  vColor = mix(uColor, uColor2, aRnd.w); // two-tone dust: per-particle blend of the two colours
@@ -1030,7 +1104,24 @@ uniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak
1030
1104
  varying float vAlpha;
1031
1105
  varying vec3 vColor;
1032
1106
  varying vec2 vDir;
1107
+ // User artwork (shape "sprite"), behind a define so a field without one compiles the exact same
1108
+ // program — and so the sampler only exists once a texture is actually bound to it. ONE texture is
1109
+ // shared by every particle in the field; see ParticleField.loadSprite for the rasterization.
1110
+ #ifdef PARTICLE_SPRITE
1111
+ uniform sampler2D uSprite;
1112
+ #endif
1033
1113
  void main(){
1114
+ #ifdef PARTICLE_SPRITE
1115
+ // gl_PointCoord's origin is the sprite's TOP-left with y running DOWN, so it has to be flipped or
1116
+ // every sprite draws upside down. The procedural shapes below never needed this — they are all
1117
+ // symmetric about y, which is exactly why the bug would have gone unnoticed.
1118
+ vec4 tex = texture2D(uSprite, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
1119
+ float a = tex.a * vAlpha;
1120
+ if (a <= 0.0) discard;
1121
+ // Tinted by the dust colour so color / color2 keep working: white artwork takes the tint
1122
+ // exactly, coloured artwork multiplies it.
1123
+ gl_FragColor = vec4(vColor * tex.rgb, a);
1124
+ #else
1034
1125
  vec2 pc = gl_PointCoord - 0.5;
1035
1126
  float d = length(pc);
1036
1127
  int s = int(uShape + 0.5);
@@ -1053,6 +1144,7 @@ void main(){
1053
1144
  a *= vAlpha;
1054
1145
  if (a <= 0.0) discard;
1055
1146
  gl_FragColor = vec4(vColor, a); // AdditiveBlending (src = SrcAlpha) → adds vColor·a
1147
+ #endif
1056
1148
  }
1057
1149
  `;
1058
1150
  //#endregion