@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.
- package/README.md +3 -1
- package/dist/config/model.d.ts +78 -4
- package/dist/config/model.js +12 -2
- package/dist/config/model.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/presets.js +35 -0
- package/dist/presets.js.map +1 -1
- package/dist/renderer/WaveRenderer.d.ts +146 -12
- package/dist/renderer/WaveRenderer.js +218 -56
- package/dist/renderer/WaveRenderer.js.map +1 -1
- package/dist/renderer/WaveRendererGPU.js +291 -0
- package/dist/renderer/WaveRendererGPU.js.map +1 -0
- package/dist/renderer/gpu-loader.js +2 -0
- package/dist/renderer/index.d.ts +3 -2
- package/dist/renderer/interaction.d.ts +29 -0
- package/dist/renderer/interaction.js +77 -31
- package/dist/renderer/interaction.js.map +1 -1
- package/dist/renderer/interactionGates.js +59 -0
- package/dist/renderer/interactionGates.js.map +1 -0
- package/dist/renderer/particleField.d.ts +1 -28
- package/dist/renderer/particleField.js +173 -16
- package/dist/renderer/particleField.js.map +1 -1
- package/dist/renderer/particleFieldGPU.js +141 -0
- package/dist/renderer/particleFieldGPU.js.map +1 -0
- package/dist/renderer/shaders.js +178 -86
- package/dist/renderer/shaders.js.map +1 -1
- package/dist/renderer/tilt.d.ts +17 -0
- package/dist/renderer/tilt.js +124 -0
- package/dist/renderer/tilt.js.map +1 -0
- package/dist/renderer/tsl/color.js +129 -0
- package/dist/renderer/tsl/color.js.map +1 -0
- package/dist/renderer/tsl/noise.js +83 -0
- package/dist/renderer/tsl/noise.js.map +1 -0
- package/dist/renderer/tsl/packedArray.js +76 -0
- package/dist/renderer/tsl/packedArray.js.map +1 -0
- package/dist/renderer/tsl/particleMaterial.js +133 -0
- package/dist/renderer/tsl/particleMaterial.js.map +1 -0
- package/dist/renderer/tsl/particleUniforms.js +44 -0
- package/dist/renderer/tsl/particleUniforms.js.map +1 -0
- package/dist/renderer/tsl/pointerField.js +73 -0
- package/dist/renderer/tsl/pointerField.js.map +1 -0
- package/dist/renderer/tsl/post.js +63 -0
- package/dist/renderer/tsl/post.js.map +1 -0
- package/dist/renderer/tsl/postChain.js +56 -0
- package/dist/renderer/tsl/postChain.js.map +1 -0
- package/dist/renderer/tsl/postEffects.js +221 -0
- package/dist/renderer/tsl/postEffects.js.map +1 -0
- package/dist/renderer/tsl/types.js +28 -0
- package/dist/renderer/tsl/types.js.map +1 -0
- package/dist/renderer/tsl/uniforms.js +152 -0
- package/dist/renderer/tsl/uniforms.js.map +1 -0
- package/dist/renderer/tsl/waveMaterial.js +185 -0
- package/dist/renderer/tsl/waveMaterial.js.map +1 -0
- package/dist/renderer/tsl/waveShape.js +108 -0
- package/dist/renderer/tsl/waveShape.js.map +1 -0
- package/dist/shell/createWave.d.ts +29 -0
- package/dist/shell/createWave.js +39 -11
- package/dist/shell/createWave.js.map +1 -1
- package/dist/shell/probe.js +18 -1
- package/dist/shell/probe.js.map +1 -1
- package/dist/standalone/wave3d.standalone.js +2219 -1929
- package/dist/standalone/wave3d.standalone.webgpu.js +36647 -0
- package/dist/standalone.d.ts +10 -3
- package/dist/standalone.js +10 -3
- package/dist/standalone.js.map +1 -1
- package/dist/studio/StudioWaveRenderer.d.ts +4 -0
- package/dist/studio/StudioWaveRenderer.js +9 -0
- package/dist/studio/StudioWaveRenderer.js.map +1 -1
- package/dist/studio/StudioWaveRendererGPU.js +18 -0
- package/dist/studio/StudioWaveRendererGPU.js.map +1 -0
- package/dist/studio/index.d.ts +12 -2
- package/dist/studio/index.js +14 -1
- package/dist/studio/index.js.map +1 -0
- package/package.json +12 -3
- 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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"}
|
package/dist/renderer/shaders.js
CHANGED
|
@@ -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)
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|