@wave3d/core 0.10.0 → 0.11.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 (53) hide show
  1. package/dist/config/model.d.ts +209 -6
  2. package/dist/config/model.js +85 -3
  3. package/dist/config/model.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/presets.js +295 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveGeometry.js +21 -0
  9. package/dist/renderer/WaveGeometry.js.map +1 -1
  10. package/dist/renderer/WaveRenderer.d.ts +62 -0
  11. package/dist/renderer/WaveRenderer.js +349 -10
  12. package/dist/renderer/WaveRenderer.js.map +1 -1
  13. package/dist/renderer/WaveRendererGPU.js +32 -2
  14. package/dist/renderer/WaveRendererGPU.js.map +1 -1
  15. package/dist/renderer/interaction.js +12 -0
  16. package/dist/renderer/interaction.js.map +1 -1
  17. package/dist/renderer/particleField.js +26 -2
  18. package/dist/renderer/particleField.js.map +1 -1
  19. package/dist/renderer/particleFieldGPU.js +6 -0
  20. package/dist/renderer/particleFieldGPU.js.map +1 -1
  21. package/dist/renderer/shaders.js +748 -13
  22. package/dist/renderer/shaders.js.map +1 -1
  23. package/dist/renderer/tsl/dissolve.js +56 -0
  24. package/dist/renderer/tsl/dissolve.js.map +1 -0
  25. package/dist/renderer/tsl/particleMaterial.js +46 -8
  26. package/dist/renderer/tsl/particleMaterial.js.map +1 -1
  27. package/dist/renderer/tsl/uniforms.js +40 -1
  28. package/dist/renderer/tsl/uniforms.js.map +1 -1
  29. package/dist/renderer/tsl/waveMaterial.js +275 -26
  30. package/dist/renderer/tsl/waveMaterial.js.map +1 -1
  31. package/dist/renderer/tsl/waveShape.js +31 -10
  32. package/dist/renderer/tsl/waveShape.js.map +1 -1
  33. package/dist/renderer/wavePath.js +189 -0
  34. package/dist/renderer/wavePath.js.map +1 -0
  35. package/dist/shell/createWave.d.ts +23 -3
  36. package/dist/shell/createWave.js +5 -4
  37. package/dist/shell/createWave.js.map +1 -1
  38. package/dist/shell/probe.d.ts +28 -0
  39. package/dist/shell/probe.js +58 -11
  40. package/dist/shell/probe.js.map +1 -1
  41. package/dist/standalone/wave3d.standalone.js +3401 -2108
  42. package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
  43. package/dist/standalone.d.ts +2 -2
  44. package/dist/standalone.js +2 -2
  45. package/dist/studio/StudioWaveRenderer.d.ts +115 -8
  46. package/dist/studio/StudioWaveRenderer.js +588 -14
  47. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  48. package/dist/studio/index.d.ts +2 -2
  49. package/dist/studio/index.js.map +1 -1
  50. package/dist/studio/randomize.js +0 -1
  51. package/dist/studio/randomize.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/wave3d/SKILL.md +142 -5
@@ -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 { 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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"}
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 square: 5,\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 \"uPathTex\",\n \"uHelixPhase\",\n \"uRadialAmount\",\n \"uRadialArc\",\n \"uRadialSpread\",\n \"uRadialRadius\",\n \"uRadialCenter\",\n \"uRadialCone\",\n \"uRadialSwirl\",\n // Dissolve: the front the ribbon is crumbling along, plus how hard this field is pinned to it.\n // Mirrored like everything else here, so the dust leaves exactly where the surface goes.\n \"uDissolveAmount\",\n \"uDissolveBand\",\n \"uDissolveScale\",\n \"uDissolveBlocky\",\n \"uDissolveAxis\",\n \"uDissolveReverse\",\n \"uDissolveDust\",\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 uPathTex: { value: null as THREE.Texture | null },\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 uRadialCone: { value: 0 },\n uRadialSwirl: { value: 0 },\n uDissolveAmount: { value: 0 },\n uDissolveBand: { value: 0.35 },\n uDissolveScale: { value: 90 },\n uDissolveBlocky: { value: 0.6 },\n uDissolveAxis: { value: 0 },\n uDissolveReverse: { value: 0 },\n uDissolveDust: { value: 1 },\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 // Additive can only brighten, so a dark mote on a pale page needs plain alpha compositing\n // instead. Assigning the same value is free; three only re-resolves state when it changes.\n const blend = cfg.blend === \"normal\" ? THREE.NormalBlending : THREE.AdditiveBlending;\n if (this.material.blending !== blend) this.material.blending = blend;\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 // Textures (the path LUT) are shared BY REFERENCE, like the ripple arrays above: the wave owns\n // the texture and rewrites it in place, so the dust rides the same centreline with no copy.\n else if (from.value === null || (from.value as THREE.Texture | null)?.isTexture) {\n 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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"}
@@ -34,6 +34,9 @@ var ParticleFieldGPU = class {
34
34
  disposed = false;
35
35
  /** The variant the current material was built for; a change rebuilds the graph. */
36
36
  variant = "";
37
+ /** Compositing mode, kept here so a graph rebuild re-applies it (a fresh material defaults to
38
+ * additive). See {@link ParticlesConfig.blend}. */
39
+ blend = THREE.AdditiveBlending;
37
40
  constructor(host, onReady) {
38
41
  this.host = host;
39
42
  this.onReady = onReady;
@@ -73,6 +76,8 @@ var ParticleFieldGPU = class {
73
76
  u.uWander.value = cfg.wander ?? 0;
74
77
  u.uShape.value = SHAPE_INDEX[cfg.shape ?? "glitter"] ?? 0;
75
78
  u.uPartShove.value = cfg.pointerShove ?? 1;
79
+ if (this.material) this.material.blending = cfg.blend === "normal" ? THREE.NormalBlending : THREE.AdditiveBlending;
80
+ this.blend = cfg.blend === "normal" ? THREE.NormalBlending : THREE.AdditiveBlending;
76
81
  setLinear(u.uColor.value, cfg.color ?? "#ffcf8a");
77
82
  setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? "#ffcf8a");
78
83
  this.syncSprite(cfg.shape === "sprite" ? cfg.spriteUrl ?? "" : "");
@@ -104,6 +109,7 @@ var ParticleFieldGPU = class {
104
109
  this.variant = key;
105
110
  this.material?.dispose();
106
111
  this.material = buildParticleMaterial(this.host.uniforms, this.uniforms, this.attrs, flags, this.sprite ?? null);
112
+ this.material.blending = this.blend;
107
113
  this.object.material = this.material;
108
114
  }
109
115
  syncSprite(url) {
@@ -1 +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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"}
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 /** Compositing mode, kept here so a graph rebuild re-applies it (a fresh material defaults to\n * additive). See {@link ParticlesConfig.blend}. */\n private blend: THREE.Blending = THREE.AdditiveBlending;\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 // Additive can only brighten, so a dark mote on a pale page needs plain alpha compositing.\n if (this.material) {\n this.material.blending =\n cfg.blend === \"normal\" ? THREE.NormalBlending : THREE.AdditiveBlending;\n }\n this.blend = cfg.blend === \"normal\" ? THREE.NormalBlending : THREE.AdditiveBlending;\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.material.blending = this.blend;\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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"}