@wave3d/core 0.8.0 → 0.10.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 +48 -2
- package/dist/config/model.js +8 -1
- package/dist/config/model.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/renderer/WaveRenderer.d.ts +142 -10
- package/dist/renderer/WaveRenderer.js +209 -53
- 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 -59
- package/dist/renderer/particleField.js +49 -29
- 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/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 +1811 -1594
- 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":"interaction.js","names":[],"sources":["../../src/renderer/interaction.ts"],"sourcesContent":["// The optional interactivity runtime for the wave renderer: a per-wave pointer field (localized\n// cursor effects) + per-wave and scene input→param bindings, driven by ONE shared cursor/scroll.\n// It lives in renderer/ so it stays below the shell/studio/index layers (depcruise); it may import\n// only `three`, ../config/model, and ../util/math.\n//\n// Split of responsibility with WaveRenderer: this controller owns ALL input + smoothing (the one\n// cursor's position / presence / press / velocity, scroll progress + velocity, the `appear` latch,\n// custom inputs, click ripples, and every binding's smoothed 0..1 source value — keyed by binding\n// identity so scene + per-wave binding lists all get their own smoothing). The renderer reads\n// sample() / bindingValue() once per frame and writes uniforms — the pointer field per wave, and\n// bindings via the WAVE_APPLIERS / SCENE_APPLIERS tables. Bindings NEVER mutate `config`.\nimport * as THREE from \"three\";\nimport { clamp01 } from \"../util/math\";\nimport type {\n InteractionSource,\n SceneInteractionBinding,\n SceneInteractionTarget,\n StudioConfig,\n WaveConfig,\n WaveInteractionBinding,\n WaveInteractionTarget,\n} from \"../config/model\";\n\n/** Click-ripple ring-buffer size. MUST match the `[4]` array sizes in shaders.ts (POINTER_RIPPLES). */\nexport const RIPPLE_SLOTS = 4;\nconst RIPPLE_LIFETIME = 1.5; // seconds a ripple lives (crest travels out + fades by then)\nconst VELOCITY_TAU = 0.08; // pointer-velocity smoothing time constant (seconds)\nconst POINTER_SPEED_REF = 4.0; // NDC/s that normalizes pointerSpeed to 1.0\nconst SCROLL_VELOCITY_REF = 2.0; // progress/s that normalizes scrollVelocity to 1.0\nconst SCROLL_VELOCITY_TAU = 0.15; // scroll-velocity smoothing (seconds)\nconst DEFAULT_POINTER_TAU = 0.12; // pointer-follow smoothing default (seconds)\nconst DEFAULT_BINDING_TAU = 0.25; // per-binding source smoothing default (seconds)\nconst POINTER_SPRING_ZETA = 0.7; // pointer-field damping ratio (<1 → slight overshoot = \"weight\")\nconst MIN_POINTER_TAU = 0.02; // floor before smoothing→spring frequency (omega = 1/tau)\nconst SPRING_MAX_STEP = 1 / 120; // substep the spring below this dt so it stays stable after a stall\nconst SPRING_MAX_SUBSTEPS = 6;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */\nfunction alpha(tau: number, dt: number): number {\n return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;\n}\n\n/**\n * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)\n * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio\n * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this\n * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps\n * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.\n */\nfunction springVec2(\n pos: THREE.Vector2,\n vel: THREE.Vector2,\n target: THREE.Vector2,\n omega: number,\n zeta: number,\n dt: number,\n): void {\n if (dt <= 0) return;\n const steps =\n dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;\n const h = dt / steps;\n const k = omega * omega;\n const c = 2 * zeta * omega;\n for (let s = 0; s < steps; s++) {\n vel.x += (k * (target.x - pos.x) - c * vel.x) * h;\n vel.y += (k * (target.y - pos.y) - c * vel.y) * h;\n pos.x += vel.x * h;\n pos.y += vel.y * h;\n }\n}\n\n// ---- Binding applier tables -----------------------------------------------------------------\n\n/** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */\nexport interface WaveApplyArgs {\n u: Record<string, THREE.IUniform>;\n mesh: THREE.Object3D;\n}\n/** What a scene-scoped applier writes into: the post-pass uniforms + a small out-param the renderer\n * seeds (0 / 1) each frame and reads back (the interaction time-offset + zoom multiplier). */\nexport interface SceneApplyArgs {\n post: Record<string, THREE.IUniform>;\n out: { timeOffset: number; zoom: number };\n}\ninterface WaveApplier {\n base(w: WaveConfig): number;\n apply(value: number, a: WaveApplyArgs): void;\n}\ninterface SceneApplier {\n base(c: StudioConfig): number;\n apply(value: number, a: SceneApplyArgs): void;\n}\n\nconst waveApplier = (\n base: (w: WaveConfig) => number,\n apply: (value: number, a: WaveApplyArgs) => void,\n): WaveApplier => ({ base, apply });\nconst sceneApplier = (\n base: (c: StudioConfig) => number,\n apply: (value: number, a: SceneApplyArgs) => void,\n): SceneApplier => ({ base, apply });\n\n/**\n * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).\n * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source\n * 0) writes the same value the renderer already had — no visible jump. This object is the runtime\n * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).\n */\nexport const WAVE_APPLIERS = {\n displaceAmount: waveApplier(\n (w) => w.displaceAmount,\n (v, a) => {\n a.u.uDispAmount.value = v;\n },\n ),\n detailAmount: waveApplier(\n (w) => w.detailAmount ?? 0,\n (v, a) => {\n a.u.uDetailAmount.value = v;\n },\n ),\n twistPowerX: waveApplier(\n (w) => w.twistPower.x,\n (v, a) => {\n a.u.uTwPowX.value = v;\n },\n ),\n twistPowerY: waveApplier(\n (w) => w.twistPower.y,\n (v, a) => {\n a.u.uTwPowY.value = v;\n },\n ),\n twistPowerZ: waveApplier(\n (w) => w.twistPower.z,\n (v, a) => {\n a.u.uTwPowZ.value = v;\n },\n ),\n twistFrequencyX: waveApplier(\n (w) => w.twistFrequency.x,\n (v, a) => {\n a.u.uTwFreqX.value = v;\n },\n ),\n twistFrequencyY: waveApplier(\n (w) => w.twistFrequency.y,\n (v, a) => {\n a.u.uTwFreqY.value = v;\n },\n ),\n twistFrequencyZ: waveApplier(\n (w) => w.twistFrequency.z,\n (v, a) => {\n a.u.uTwFreqZ.value = v;\n },\n ),\n hueShift: waveApplier(\n (w) => w.hueShift,\n (v, a) => {\n a.u.uHueShift.value = v;\n },\n ),\n gradientShift: waveApplier(\n (w) => w.gradientShift ?? 0,\n (v, a) => {\n a.u.uGradShift.value = v;\n },\n ),\n colorSaturation: waveApplier(\n (w) => w.colorSaturation,\n (v, a) => {\n a.u.uSaturation.value = v;\n },\n ),\n opacity: waveApplier(\n (w) => w.opacity,\n (v, a) => {\n a.u.uOpacity.value = v;\n },\n ),\n lineThickness: waveApplier(\n (w) => w.lineThickness ?? 1,\n (v, a) => {\n a.u.uLineThickness.value = v;\n },\n ),\n lineAmount: waveApplier(\n (w) => w.lineAmount ?? 425,\n (v, a) => {\n a.u.uLineAmount.value = v;\n },\n ),\n fiberStrength: waveApplier(\n (w) => w.fiberStrength,\n (v, a) => {\n a.u.uFiberStrength.value = v;\n },\n ),\n sheen: waveApplier(\n (w) => w.sheen ?? 1,\n (v, a) => {\n a.u.uSheen.value = v;\n },\n ),\n iridescence: waveApplier(\n (w) => w.iridescence ?? 0,\n (v, a) => {\n a.u.uIridescence.value = v;\n },\n ),\n // Helix: phase spins the coil, turns winds/unwinds it, radius opens and closes it. All three are\n // inert unless the wave already has a helix, so waveDefines compiles HELIX for a wave that binds\n // one but authors radius/roll at 0 (same reason detailAmount does — see bindsDetail there).\n helixPhase: waveApplier(\n (w) => w.helixPhase ?? 0,\n (v, a) => {\n a.u.uHelixPhase.value = v;\n },\n ),\n helixTurns: waveApplier(\n (w) => w.helixTurns ?? 0,\n (v, a) => {\n a.u.uHelixTurns.value = v;\n },\n ),\n helixRadius: waveApplier(\n (w) => w.helixRadius ?? 0,\n (v, a) => {\n a.u.uHelixRadius.value = v;\n },\n ),\n positionX: waveApplier(\n (w) => w.position.x,\n (v, a) => {\n a.mesh.position.x = v;\n },\n ),\n positionY: waveApplier(\n (w) => w.position.y,\n (v, a) => {\n a.mesh.position.y = v;\n },\n ),\n} satisfies Record<WaveInteractionTarget, WaveApplier>;\n\n/** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */\nexport const SCENE_APPLIERS = {\n timeOffset: sceneApplier(\n (c) => c.timeOffset ?? 0,\n (v, a) => {\n a.out.timeOffset = v;\n },\n ),\n cameraZoom: sceneApplier(\n (c) => c.cameraZoom ?? 1,\n (v, a) => {\n a.out.zoom = v;\n },\n ),\n blur: sceneApplier(\n (c) => c.blur,\n (v, a) => {\n a.post.uBlurAmount.value = v;\n },\n ),\n grain: sceneApplier(\n (c) => c.grain,\n (v, a) => {\n a.post.uGrainAmount.value = v;\n },\n ),\n} satisfies Record<SceneInteractionTarget, SceneApplier>;\n\n// ---- Active-state predicates (keyed off config only, so input never triggers a recompile) ----\n\n/** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */\nfunction notDisabled(cfg: StudioConfig): boolean {\n return cfg.interaction?.enabled !== false;\n}\n\n/** Whether a wave has a pointer field (hover effects, or a click ripple). */\nfunction waveHasPointerField(w: WaveConfig): boolean {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);\n}\n\n/** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */\nexport function wavePointerFxActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && waveHasPointerField(w);\n}\n\n/** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */\nexport function waveRipplesActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;\n}\n\n/** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */\nexport function anyPointerFxActive(cfg: StudioConfig): boolean {\n return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);\n}\n\n/** Whether the interaction layer should run at all (any wave interaction, or any scene binding). */\nexport function interactionActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;\n return cfg.waves.some((w) => {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);\n });\n}\n\n// ---- Sample shape + the controller ----------------------------------------------------------\n\ninterface RippleSlot {\n origin: THREE.Vector2; // NDC\n age: number; // seconds since spawn\n amp: number; // 0..1 decay envelope (0 = free slot)\n}\ninterface RippleState extends RippleSlot {\n active: boolean;\n}\n\n/** A per-frame snapshot of the pointer-field state. Fields are LIVE references into the controller's\n * state — read them synchronously each frame; don't retain them. */\nexport interface InteractionSample {\n /** Smoothed pointer position, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Smoothed pointer presence 0..1 (→ uPointerActive). */\n presence: number;\n /** Click-ripple ring buffer (amp = shared 0..1 envelope; 0 = free slot). */\n ripples: readonly RippleSlot[];\n}\n\n/** A wave's own smoothed pointer-field state — trails the shared cursor at the wave's own rate. */\nexport interface PointerField {\n /** Smoothed pointer position for this wave, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Spring velocity of `ndc` (NDC/s) — internal spring state, not read by the renderer. */\n vel: THREE.Vector2;\n /** Smoothed pointer presence 0..1 for this wave. */\n presence: number;\n}\n\n/**\n * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the\n * renderer when {@link interactionActive} first turns true, disposed when it turns false. All\n * listeners are passive and container-scoped (the poster overlay passes events through).\n */\nexport class InteractionController {\n /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */\n scrollOverride: number | null = null;\n\n private readonly ndc = new THREE.Vector2();\n private readonly ndcTarget = new THREE.Vector2();\n private readonly ndcPrev = new THREE.Vector2();\n private readonly velNdc = new THREE.Vector2();\n private presence = 0;\n private presenceTarget = 0;\n private press = 0;\n private pressTarget = 0;\n private pointerSpeed = 0;\n private scroll = 0;\n private scrollPrev = 0;\n private scrollVel = 0;\n private appearLatched = false;\n private readonly customInputs = new Map<string, number>();\n private readonly ripples: RippleState[] = [];\n // Per-wave pointer-field state (index-parallel to config.waves); each trails the cursor at its own\n // hover smoothing. Grown/shrunk in update().\n private readonly fields: PointerField[] = [];\n // Per-binding smoothing state, keyed by binding-object identity (covers scene + every wave list).\n private readonly bindingState = new Map<\n AnyBinding,\n { value: number; source: InteractionSource }\n >();\n // Scratch set reused by updateBindings every frame (cleared, never reallocated).\n private readonly seenBindings = new Set<AnyBinding>();\n private readonly out: InteractionSample;\n\n constructor(\n private readonly container: HTMLElement,\n private readonly cfg: () => StudioConfig | undefined,\n ) {\n for (let i = 0; i < RIPPLE_SLOTS; i++) {\n this.ripples.push({ origin: new THREE.Vector2(), age: 0, amp: 0, active: false });\n }\n this.out = { ndc: this.ndc, presence: 0, ripples: this.ripples };\n const opts = { passive: true } as const;\n container.addEventListener(\"pointerenter\", this.onPointerEnter, opts);\n container.addEventListener(\"pointermove\", this.onPointerMove, opts);\n container.addEventListener(\"pointerleave\", this.onPointerLeave, opts);\n container.addEventListener(\"pointercancel\", this.onPointerCancel, opts);\n container.addEventListener(\"pointerdown\", this.onPointerDown, opts);\n container.addEventListener(\"pointerup\", this.onPointerUp, opts);\n }\n\n /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */\n private ignore(e: PointerEvent): boolean {\n return e.pointerType === \"touch\" && this.cfg()?.interaction?.touch !== true;\n }\n\n private setNdcTarget(e: PointerEvent): void {\n const rect = this.container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n this.ndcTarget.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -(((e.clientY - rect.top) / rect.height) * 2 - 1),\n );\n }\n\n private onPointerEnter = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerMove = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n if (e.pointerType === \"touch\" && this.pressTarget < 0.5) return; // touch: only track while down\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerLeave = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0); // relax toward centre → pointerX/Y rest at 0.5\n };\n private onPointerCancel = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0);\n };\n private onPointerDown = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 1;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n // Spawn a ripple only if some wave actually wants ripples (else it is wasted state).\n const cfg = this.cfg();\n if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();\n };\n private onPointerUp = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n if (e.pointerType === \"touch\") {\n this.presenceTarget = 0; // touch has no hover — presence ends with the touch\n this.ndcTarget.set(0, 0);\n }\n };\n\n /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the\n * shader. Reuses a free slot or evicts the oldest. */\n private spawnRipple(): void {\n let slot = this.ripples.find((r) => !r.active);\n if (!slot) {\n slot = this.ripples[0];\n for (const r of this.ripples) if (r.age > slot.age) slot = r;\n }\n slot.origin.copy(this.ndcTarget);\n slot.age = 0;\n slot.amp = 1;\n slot.active = true;\n }\n\n /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */\n update(dt: number): void {\n const cfg = this.cfg();\n if (!cfg) return;\n const d = Math.max(dt, 0);\n // The SHARED pointer state feeds binding sources (hover / pointerX-Y / pointerSpeed / press) at a\n // fixed baseline; each wave's FIELD trails at its own hover smoothing further below.\n const kPointer = alpha(DEFAULT_POINTER_TAU, d);\n\n // Pointer position + presence + press.\n this.ndcPrev.copy(this.ndc);\n this.ndc.lerp(this.ndcTarget, kPointer);\n this.presence += (this.presenceTarget - this.presence) * kPointer;\n this.press += (this.pressTarget - this.press) * kPointer;\n\n // Velocity (own tau) from the smoothed-position delta.\n if (d > 1e-5) {\n const kv = alpha(VELOCITY_TAU, d);\n this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;\n this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;\n }\n this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);\n\n // Per-wave pointer FIELD: each wave's position is a damped SPRING toward the raw cursor target,\n // so a stack trails the cursor at different rates (parallax) and — because the spring is slightly\n // underdamped — carries a little weight: it overshoots and settles instead of creeping to a dead\n // stop. Presence stays a plain ramp (a spring there could dip below 0 and invert the effect).\n // A wave's hover `smoothing` sets the spring frequency (omega = 1/tau).\n const waves = cfg.waves;\n if (this.fields.length > waves.length) this.fields.length = waves.length;\n for (let i = 0; i < waves.length; i++) {\n let f = this.fields[i];\n if (!f) {\n f = {\n ndc: this.ndcTarget.clone(),\n vel: new THREE.Vector2(),\n presence: this.presenceTarget,\n };\n this.fields[i] = f;\n }\n const tau = Math.max(\n waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU,\n MIN_POINTER_TAU,\n );\n springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);\n f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);\n }\n\n // Scroll progress + velocity.\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n if (d > 1e-5) {\n const sv = Math.abs(rawScroll - this.scrollPrev) / d;\n this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);\n }\n this.scrollPrev = rawScroll;\n this.scroll = rawScroll;\n\n // Appear latch: the render loop is visibility-gated, so the first update() IS first-visible.\n this.appearLatched = true;\n\n // Ripples: age + quadratic-decay envelope.\n for (const r of this.ripples) {\n if (!r.active) continue;\n r.age += d;\n const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);\n r.amp = env * env;\n if (r.amp <= 0) r.active = false;\n }\n\n this.updateBindings(cfg, d);\n }\n\n // Indexed loops + a reused scratch set (no per-frame closure/array/Set) — this runs every frame.\n private updateBindings(cfg: StudioConfig, dt: number): void {\n const seen = this.seenBindings;\n seen.clear();\n const sceneBindings = cfg.interaction?.bindings;\n if (sceneBindings) {\n for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);\n }\n for (let w = 0; w < cfg.waves.length; w++) {\n const bindings = cfg.waves[w].interaction?.bindings;\n if (!bindings) continue;\n for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);\n }\n // Prune state for bindings that no longer exist (edited/removed slots). advanceBinding puts every\n // seen binding in the map, so map ⊇ seen — equal sizes means nothing is stale to walk for.\n if (this.bindingState.size > seen.size) {\n for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);\n }\n }\n\n /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */\n private advanceBinding(b: AnyBinding, dt: number): void {\n this.seenBindings.add(b);\n const raw = this.rawSource(b.source);\n let st = this.bindingState.get(b);\n // (Re)initialise on first sight or when the slot's source changed (studio edit): `appear`\n // ramps from 0 (entrance), every other source snaps to its current value.\n if (!st || st.source !== b.source) {\n st = { value: b.source === \"appear\" ? 0 : raw, source: b.source };\n this.bindingState.set(b, st);\n }\n st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);\n }\n\n /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */\n bindingValue(b: AnyBinding): number {\n return this.bindingState.get(b)?.value ?? 0;\n }\n\n /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */\n private rawSource(source: InteractionSource): number {\n switch (source) {\n case \"scroll\":\n return this.scroll;\n case \"hover\":\n return this.presence;\n case \"pointerX\":\n return (this.ndc.x + 1) * 0.5;\n case \"pointerY\":\n return (this.ndc.y + 1) * 0.5;\n case \"pointerSpeed\":\n return this.pointerSpeed;\n case \"press\":\n return this.press;\n case \"scrollVelocity\":\n return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);\n case \"appear\":\n return this.appearLatched ? 1 : 0;\n default:\n // custom:<name> — fed by setInput(name, value).\n return this.customInputs.get(source.slice(\"custom:\".length)) ?? 0;\n }\n }\n\n /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */\n private computeScroll(): number {\n const rect = this.container.getBoundingClientRect();\n const vh = window.innerHeight || document.documentElement.clientHeight || 1;\n return clamp01((vh - rect.top) / (vh + rect.height));\n }\n\n /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */\n sample(): InteractionSample {\n this.out.presence = this.presence;\n return this.out;\n }\n\n /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or\n * null if the wave hasn't been advanced by update() yet (treat as rest). */\n fieldFor(waveIdx: number): PointerField | null {\n return this.fields[waveIdx] ?? null;\n }\n\n /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The\n * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead\n * of buzzing at a constant rate whenever the cursor is merely present. */\n pointerFlux(): number {\n return this.pointerSpeed;\n }\n\n /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the\n * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */\n pointerVelocity(): THREE.Vector2 {\n return this.velNdc;\n }\n\n /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */\n setInput(name: string, value: number): void {\n if (typeof name !== \"string\" || !Number.isFinite(value)) return;\n this.customInputs.set(name, value);\n }\n\n /**\n * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /\n * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,\n * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must\n * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps\n * to its settled source so the one settled frame shows the final look.\n */\n settle(): void {\n this.presence = this.presenceTarget = 0;\n this.press = this.pressTarget = 0;\n this.pointerSpeed = 0;\n this.velNdc.set(0, 0);\n this.ndc.set(0, 0);\n this.ndcTarget.set(0, 0);\n this.ndcPrev.set(0, 0);\n for (const f of this.fields) {\n f.ndc.set(0, 0);\n f.vel.set(0, 0);\n f.presence = 0;\n }\n for (const r of this.ripples) {\n r.age = 0;\n r.amp = 0;\n r.active = false;\n }\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = rawScroll;\n this.scrollVel = 0;\n this.appearLatched = true;\n const cfg = this.cfg();\n if (cfg) {\n this.bindingState.clear();\n const snap = (b: AnyBinding): void => {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n }\n\n /**\n * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving\n * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the\n * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a\n * manual scrub that must reflect the instant you move it — not on the next animation frame, which\n * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses\n * ALL input to rest for a paused still frame), this touches only the scroll signal.\n */\n snapScroll(): void {\n const raw = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = raw;\n this.scrollVel = 0; // a static scrub has no velocity\n const cfg = this.cfg();\n if (!cfg) return;\n const snap = (b: AnyBinding): void => {\n if (b.source === \"scroll\" || b.source === \"scrollVelocity\") {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n }\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n\n dispose(): void {\n const c = this.container;\n c.removeEventListener(\"pointerenter\", this.onPointerEnter);\n c.removeEventListener(\"pointermove\", this.onPointerMove);\n c.removeEventListener(\"pointerleave\", this.onPointerLeave);\n c.removeEventListener(\"pointercancel\", this.onPointerCancel);\n c.removeEventListener(\"pointerdown\", this.onPointerDown);\n c.removeEventListener(\"pointerup\", this.onPointerUp);\n this.customInputs.clear();\n this.bindingState.clear();\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"interaction.js","names":[],"sources":["../../src/renderer/interaction.ts"],"sourcesContent":["// The optional interactivity runtime for the wave renderer: a per-wave pointer field (localized\n// cursor effects) + per-wave and scene input→param bindings, driven by ONE shared cursor/scroll.\n// It lives in renderer/ so it stays below the shell/studio/index layers (depcruise); it may import\n// only `three`, ../config/model, and ../util/math.\n//\n// Split of responsibility with WaveRenderer: this controller owns ALL input + smoothing (the one\n// cursor's position / presence / press / velocity, scroll progress + velocity, the `appear` latch,\n// custom inputs, click ripples, and every binding's smoothed 0..1 source value — keyed by binding\n// identity so scene + per-wave binding lists all get their own smoothing). The renderer reads\n// sample() / bindingValue() once per frame and writes uniforms — the pointer field per wave, and\n// bindings via the WAVE_APPLIERS / SCENE_APPLIERS tables. Bindings NEVER mutate `config`.\nimport * as THREE from \"three\";\nimport { clamp01 } from \"../util/math\";\nimport { RIPPLE_SLOTS, tiltActive } from \"./interactionGates\";\nimport { type TiltStatus, TiltSource } from \"./tilt\";\nimport type {\n InteractionSource,\n SceneInteractionBinding,\n SceneInteractionTarget,\n StudioConfig,\n WaveConfig,\n WaveInteractionBinding,\n WaveInteractionTarget,\n} from \"../config/model\";\n\nconst RIPPLE_LIFETIME = 1.5; // seconds a ripple lives (crest travels out + fades by then)\nconst VELOCITY_TAU = 0.08; // pointer-velocity smoothing time constant (seconds)\nconst POINTER_SPEED_REF = 4.0; // NDC/s that normalizes pointerSpeed to 1.0\nconst SCROLL_VELOCITY_REF = 2.0; // progress/s that normalizes scrollVelocity to 1.0\nconst SCROLL_VELOCITY_TAU = 0.15; // scroll-velocity smoothing (seconds)\nconst DEFAULT_POINTER_TAU = 0.12; // pointer-follow smoothing default (seconds)\nconst DEFAULT_TILT_TAU = 0.18; // device-tilt smoothing default (seconds); noisier than a cursor\nconst DEFAULT_BINDING_TAU = 0.25; // per-binding source smoothing default (seconds)\nconst POINTER_SPRING_ZETA = 0.7; // pointer-field damping ratio (<1 → slight overshoot = \"weight\")\nconst MIN_POINTER_TAU = 0.02; // floor before smoothing→spring frequency (omega = 1/tau)\nconst SPRING_MAX_STEP = 1 / 120; // substep the spring below this dt so it stays stable after a stall\nconst SPRING_MAX_SUBSTEPS = 6;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */\nfunction alpha(tau: number, dt: number): number {\n return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;\n}\n\n/**\n * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)\n * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio\n * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this\n * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps\n * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.\n */\nfunction springVec2(\n pos: THREE.Vector2,\n vel: THREE.Vector2,\n target: THREE.Vector2,\n omega: number,\n zeta: number,\n dt: number,\n): void {\n if (dt <= 0) return;\n const steps =\n dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;\n const h = dt / steps;\n const k = omega * omega;\n const c = 2 * zeta * omega;\n for (let s = 0; s < steps; s++) {\n vel.x += (k * (target.x - pos.x) - c * vel.x) * h;\n vel.y += (k * (target.y - pos.y) - c * vel.y) * h;\n pos.x += vel.x * h;\n pos.y += vel.y * h;\n }\n}\n\n// ---- Binding applier tables -----------------------------------------------------------------\n\n/** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */\nexport interface WaveApplyArgs {\n u: Record<string, THREE.IUniform>;\n mesh: THREE.Object3D;\n}\n/** What a scene-scoped applier writes into: the post-pass uniforms + a small out-param the renderer\n * seeds (0 / 1) each frame and reads back (the interaction time-offset + zoom multiplier). */\nexport interface SceneApplyArgs {\n post: Record<string, THREE.IUniform>;\n out: { timeOffset: number; zoom: number };\n}\ninterface WaveApplier {\n base(w: WaveConfig): number;\n apply(value: number, a: WaveApplyArgs): void;\n}\ninterface SceneApplier {\n base(c: StudioConfig): number;\n apply(value: number, a: SceneApplyArgs): void;\n}\n\nconst waveApplier = (\n base: (w: WaveConfig) => number,\n apply: (value: number, a: WaveApplyArgs) => void,\n): WaveApplier => ({ base, apply });\nconst sceneApplier = (\n base: (c: StudioConfig) => number,\n apply: (value: number, a: SceneApplyArgs) => void,\n): SceneApplier => ({ base, apply });\n\n/**\n * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).\n * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source\n * 0) writes the same value the renderer already had — no visible jump. This object is the runtime\n * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).\n */\nexport const WAVE_APPLIERS = {\n displaceAmount: waveApplier(\n (w) => w.displaceAmount,\n (v, a) => {\n a.u.uDispAmount.value = v;\n },\n ),\n detailAmount: waveApplier(\n (w) => w.detailAmount ?? 0,\n (v, a) => {\n a.u.uDetailAmount.value = v;\n },\n ),\n twistPowerX: waveApplier(\n (w) => w.twistPower.x,\n (v, a) => {\n a.u.uTwPowX.value = v;\n },\n ),\n twistPowerY: waveApplier(\n (w) => w.twistPower.y,\n (v, a) => {\n a.u.uTwPowY.value = v;\n },\n ),\n twistPowerZ: waveApplier(\n (w) => w.twistPower.z,\n (v, a) => {\n a.u.uTwPowZ.value = v;\n },\n ),\n twistFrequencyX: waveApplier(\n (w) => w.twistFrequency.x,\n (v, a) => {\n a.u.uTwFreqX.value = v;\n },\n ),\n twistFrequencyY: waveApplier(\n (w) => w.twistFrequency.y,\n (v, a) => {\n a.u.uTwFreqY.value = v;\n },\n ),\n twistFrequencyZ: waveApplier(\n (w) => w.twistFrequency.z,\n (v, a) => {\n a.u.uTwFreqZ.value = v;\n },\n ),\n hueShift: waveApplier(\n (w) => w.hueShift,\n (v, a) => {\n a.u.uHueShift.value = v;\n },\n ),\n gradientShift: waveApplier(\n (w) => w.gradientShift ?? 0,\n (v, a) => {\n a.u.uGradShift.value = v;\n },\n ),\n colorSaturation: waveApplier(\n (w) => w.colorSaturation,\n (v, a) => {\n a.u.uSaturation.value = v;\n },\n ),\n opacity: waveApplier(\n (w) => w.opacity,\n (v, a) => {\n a.u.uOpacity.value = v;\n },\n ),\n lineThickness: waveApplier(\n (w) => w.lineThickness ?? 1,\n (v, a) => {\n a.u.uLineThickness.value = v;\n },\n ),\n lineAmount: waveApplier(\n (w) => w.lineAmount ?? 425,\n (v, a) => {\n a.u.uLineAmount.value = v;\n },\n ),\n fiberStrength: waveApplier(\n (w) => w.fiberStrength,\n (v, a) => {\n a.u.uFiberStrength.value = v;\n },\n ),\n sheen: waveApplier(\n (w) => w.sheen ?? 1,\n (v, a) => {\n a.u.uSheen.value = v;\n },\n ),\n iridescence: waveApplier(\n (w) => w.iridescence ?? 0,\n (v, a) => {\n a.u.uIridescence.value = v;\n },\n ),\n // Helix: phase spins the coil, turns winds/unwinds it, radius opens and closes it. All three are\n // inert unless the wave already has a helix, so waveDefines compiles HELIX for a wave that binds\n // one but authors radius/roll at 0 (same reason detailAmount does — see bindsDetail there).\n helixPhase: waveApplier(\n (w) => w.helixPhase ?? 0,\n (v, a) => {\n a.u.uHelixPhase.value = v;\n },\n ),\n helixTurns: waveApplier(\n (w) => w.helixTurns ?? 0,\n (v, a) => {\n a.u.uHelixTurns.value = v;\n },\n ),\n helixRadius: waveApplier(\n (w) => w.helixRadius ?? 0,\n (v, a) => {\n a.u.uHelixRadius.value = v;\n },\n ),\n positionX: waveApplier(\n (w) => w.position.x,\n (v, a) => {\n a.mesh.position.x = v;\n },\n ),\n positionY: waveApplier(\n (w) => w.position.y,\n (v, a) => {\n a.mesh.position.y = v;\n },\n ),\n} satisfies Record<WaveInteractionTarget, WaveApplier>;\n\n/** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */\nexport const SCENE_APPLIERS = {\n timeOffset: sceneApplier(\n (c) => c.timeOffset ?? 0,\n (v, a) => {\n a.out.timeOffset = v;\n },\n ),\n cameraZoom: sceneApplier(\n (c) => c.cameraZoom ?? 1,\n (v, a) => {\n a.out.zoom = v;\n },\n ),\n blur: sceneApplier(\n (c) => c.blur,\n (v, a) => {\n a.post.uBlurAmount.value = v;\n },\n ),\n grain: sceneApplier(\n (c) => c.grain,\n (v, a) => {\n a.post.uGrainAmount.value = v;\n },\n ),\n} satisfies Record<SceneInteractionTarget, SceneApplier>;\n\n// ---- Active-state predicates (keyed off config only, so input never triggers a recompile) ----\n\n// ---- Sample shape + the controller ----------------------------------------------------------\n\ninterface RippleSlot {\n origin: THREE.Vector2; // NDC\n age: number; // seconds since spawn\n amp: number; // 0..1 decay envelope (0 = free slot)\n}\ninterface RippleState extends RippleSlot {\n active: boolean;\n}\n\n/** A per-frame snapshot of the pointer-field state. Fields are LIVE references into the controller's\n * state — read them synchronously each frame; don't retain them. */\nexport interface InteractionSample {\n /** Smoothed pointer position, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Smoothed pointer presence 0..1 (→ uPointerActive). */\n presence: number;\n /** Click-ripple ring buffer (amp = shared 0..1 envelope; 0 = free slot). */\n ripples: readonly RippleSlot[];\n}\n\n/** A wave's own smoothed pointer-field state — trails the shared cursor at the wave's own rate. */\nexport interface PointerField {\n /** Smoothed pointer position for this wave, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Spring velocity of `ndc` (NDC/s) — internal spring state, not read by the renderer. */\n vel: THREE.Vector2;\n /** Smoothed pointer presence 0..1 for this wave. */\n presence: number;\n}\n\n/**\n * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the\n * renderer when {@link interactionActive} first turns true, disposed when it turns false. All\n * listeners are passive and container-scoped (the poster overlay passes events through).\n */\nexport class InteractionController {\n /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */\n scrollOverride: number | null = null;\n\n private readonly ndc = new THREE.Vector2();\n private readonly ndcTarget = new THREE.Vector2();\n private readonly ndcPrev = new THREE.Vector2();\n private readonly velNdc = new THREE.Vector2();\n private presence = 0;\n private presenceTarget = 0;\n /** Whether a REAL pointer is on the element. Tracked apart from `presenceTarget` because tilt can\n * raise presence too (tilt.pointer), and it must yield the moment a finger or cursor shows up. */\n private pointerPresent = false;\n private press = 0;\n private pressTarget = 0;\n private pointerSpeed = 0;\n private scroll = 0;\n private scrollPrev = 0;\n private scrollVel = 0;\n private appearLatched = false;\n /** The orientation sensor, built only for a scene that declares `interaction.tilt`. */\n private tilt: TiltSource | null = null;\n private tiltX = 0.5; // smoothed 0..1 (0.5 = neutral pose), mirroring the pointer's own smoothing\n private tiltY = 0.5;\n private readonly customInputs = new Map<string, number>();\n private readonly ripples: RippleState[] = [];\n // Per-wave pointer-field state (index-parallel to config.waves); each trails the cursor at its own\n // hover smoothing. Grown/shrunk in update().\n private readonly fields: PointerField[] = [];\n // Per-binding smoothing state, keyed by binding-object identity (covers scene + every wave list).\n private readonly bindingState = new Map<\n AnyBinding,\n { value: number; source: InteractionSource }\n >();\n // Scratch set reused by updateBindings every frame (cleared, never reallocated).\n private readonly seenBindings = new Set<AnyBinding>();\n private readonly out: InteractionSample;\n\n constructor(\n private readonly container: HTMLElement,\n private readonly cfg: () => StudioConfig | undefined,\n ) {\n for (let i = 0; i < RIPPLE_SLOTS; i++) {\n this.ripples.push({ origin: new THREE.Vector2(), age: 0, amp: 0, active: false });\n }\n this.out = { ndc: this.ndc, presence: 0, ripples: this.ripples };\n const opts = { passive: true } as const;\n container.addEventListener(\"pointerenter\", this.onPointerEnter, opts);\n container.addEventListener(\"pointermove\", this.onPointerMove, opts);\n container.addEventListener(\"pointerleave\", this.onPointerLeave, opts);\n container.addEventListener(\"pointercancel\", this.onPointerCancel, opts);\n container.addEventListener(\"pointerdown\", this.onPointerDown, opts);\n container.addEventListener(\"pointerup\", this.onPointerUp, opts);\n }\n\n /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */\n private ignore(e: PointerEvent): boolean {\n return e.pointerType === \"touch\" && this.cfg()?.interaction?.touch !== true;\n }\n\n private setNdcTarget(e: PointerEvent): void {\n const rect = this.container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n this.ndcTarget.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -(((e.clientY - rect.top) / rect.height) * 2 - 1),\n );\n }\n\n private onPointerEnter = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = true;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerMove = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n if (e.pointerType === \"touch\" && this.pressTarget < 0.5) return; // touch: only track while down\n this.pointerPresent = true;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerLeave = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = false;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0); // relax toward centre → pointerX/Y rest at 0.5\n };\n private onPointerCancel = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = false;\n this.pressTarget = 0;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0);\n };\n private onPointerDown = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = true;\n this.pressTarget = 1;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n // Spawn a ripple only if some wave actually wants ripples (else it is wasted state).\n const cfg = this.cfg();\n if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();\n };\n private onPointerUp = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n if (e.pointerType === \"touch\") {\n this.pointerPresent = false;\n this.presenceTarget = 0; // touch has no hover — presence ends with the touch\n this.ndcTarget.set(0, 0);\n }\n };\n\n /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the\n * shader. Reuses a free slot or evicts the oldest. */\n private spawnRipple(): void {\n let slot = this.ripples.find((r) => !r.active);\n if (!slot) {\n slot = this.ripples[0];\n for (const r of this.ripples) if (r.age > slot.age) slot = r;\n }\n slot.origin.copy(this.ndcTarget);\n slot.age = 0;\n slot.amp = 1;\n slot.active = true;\n }\n\n /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */\n update(dt: number): void {\n const cfg = this.cfg();\n if (!cfg) return;\n const d = Math.max(dt, 0);\n // The SHARED pointer state feeds binding sources (hover / pointerX-Y / pointerSpeed / press) at a\n // fixed baseline; each wave's FIELD trails at its own hover smoothing further below.\n const kPointer = alpha(DEFAULT_POINTER_TAU, d);\n\n // Device tilt, BEFORE the pointer block: with `tilt.pointer` on it writes the same ndcTarget the\n // cursor would, and everything downstream (the shared smoothing, each wave's spring) must see it\n // in the frame it changed rather than one frame late.\n this.updateTilt(cfg, d);\n\n // Pointer position + presence + press.\n this.ndcPrev.copy(this.ndc);\n this.ndc.lerp(this.ndcTarget, kPointer);\n this.presence += (this.presenceTarget - this.presence) * kPointer;\n this.press += (this.pressTarget - this.press) * kPointer;\n\n // Velocity (own tau) from the smoothed-position delta.\n if (d > 1e-5) {\n const kv = alpha(VELOCITY_TAU, d);\n this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;\n this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;\n }\n this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);\n\n // Per-wave pointer FIELD: each wave's position is a damped SPRING toward the raw cursor target,\n // so a stack trails the cursor at different rates (parallax) and — because the spring is slightly\n // underdamped — carries a little weight: it overshoots and settles instead of creeping to a dead\n // stop. Presence stays a plain ramp (a spring there could dip below 0 and invert the effect).\n // A wave's hover `smoothing` sets the spring frequency (omega = 1/tau).\n const waves = cfg.waves;\n if (this.fields.length > waves.length) this.fields.length = waves.length;\n for (let i = 0; i < waves.length; i++) {\n let f = this.fields[i];\n if (!f) {\n f = {\n ndc: this.ndcTarget.clone(),\n vel: new THREE.Vector2(),\n presence: this.presenceTarget,\n };\n this.fields[i] = f;\n }\n const tau = Math.max(\n waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU,\n MIN_POINTER_TAU,\n );\n springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);\n f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);\n }\n\n // Scroll progress + velocity.\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n if (d > 1e-5) {\n const sv = Math.abs(rawScroll - this.scrollPrev) / d;\n this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);\n }\n this.scrollPrev = rawScroll;\n this.scroll = rawScroll;\n\n // Appear latch: the render loop is visibility-gated, so the first update() IS first-visible.\n this.appearLatched = true;\n\n // Ripples: age + quadratic-decay envelope.\n for (const r of this.ripples) {\n if (!r.active) continue;\n r.age += d;\n const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);\n r.amp = env * env;\n if (r.amp <= 0) r.active = false;\n }\n\n this.updateBindings(cfg, d);\n }\n\n /**\n * Build or drop the tilt sensor as the config declares it, then advance the smoothed axes. The\n * sensor is created lazily and only for a scene that asked for it, so a cursor-only scene attaches\n * no orientation listener at all — and a studio edit that adds or removes the block is picked up\n * on the next frame, the same way the controller itself is.\n */\n private updateTilt(cfg: StudioConfig, dt: number): void {\n if (!tiltActive(cfg)) {\n if (this.tilt) {\n this.tilt.dispose();\n this.tilt = null;\n this.tiltX = this.tiltY = 0.5;\n }\n return;\n }\n const src = this.ensureTilt();\n if (!src) return;\n const tiltCfg = cfg.interaction?.tilt;\n const k = alpha(Math.max(tiltCfg?.smoothing ?? DEFAULT_TILT_TAU, 0), dt);\n this.tiltX += (src.x - this.tiltX) * k;\n this.tiltY += (src.y - this.tiltY) * k;\n\n // Opt-in stand-in for the cursor: a phone has no pointer, so a scene whose bindings and hover\n // fields all read the cursor would sit at dead centre forever. Only while no real pointer is on\n // the element — a finger beats the sensor the instant it lands. NDC y is +1 at the TOP, and\n // tiltY counts DOWN the page (the way a ball rolls), hence the negation.\n if (tiltCfg?.pointer && src.live && !this.pointerPresent) {\n this.ndcTarget.set(this.tiltX * 2 - 1, -(this.tiltY * 2 - 1));\n this.presenceTarget = 1;\n }\n }\n\n /** The sensor, built on first use. Null when the platform has none (every desktop browser). */\n private ensureTilt(): TiltSource | null {\n if (!this.tilt && TiltSource.supported()) {\n this.tilt = new TiltSource(() => this.cfg()?.interaction?.tilt);\n }\n return this.tilt;\n }\n\n /**\n * Ask for the orientation sensor, which on iOS 13+ MUST happen inside a user gesture (a tap\n * handler — not a `setTimeout` or a promise chain that outlives the gesture). Resolves true once\n * readings can flow. False means the platform has no sensor, the scene declares no `tilt` block,\n * or the reader refused. Everywhere that needs no permission, tilt is already live and this\n * simply resolves true.\n */\n async enableTilt(): Promise<boolean> {\n const cfg = this.cfg();\n if (!cfg || !tiltActive(cfg)) return false;\n return (await this.ensureTilt()?.enable()) ?? false;\n }\n\n /** Where the sensor stands — `\"prompt\"` is exactly when a tap-to-enable affordance would help. */\n tiltStatus(): TiltStatus {\n if (!TiltSource.supported()) return \"unsupported\";\n return this.tilt?.status ?? \"prompt\";\n }\n\n /** Take the next reading as the neutral pose (the reader has changed grip). */\n recenterTilt(): void {\n this.tilt?.recenter();\n }\n\n // Indexed loops + a reused scratch set (no per-frame closure/array/Set) — this runs every frame.\n private updateBindings(cfg: StudioConfig, dt: number): void {\n const seen = this.seenBindings;\n seen.clear();\n const sceneBindings = cfg.interaction?.bindings;\n if (sceneBindings) {\n for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);\n }\n for (let w = 0; w < cfg.waves.length; w++) {\n const bindings = cfg.waves[w].interaction?.bindings;\n if (!bindings) continue;\n for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);\n }\n // Prune state for bindings that no longer exist (edited/removed slots). advanceBinding puts every\n // seen binding in the map, so map ⊇ seen — equal sizes means nothing is stale to walk for.\n if (this.bindingState.size > seen.size) {\n for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);\n }\n }\n\n /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */\n private advanceBinding(b: AnyBinding, dt: number): void {\n this.seenBindings.add(b);\n const raw = this.rawSource(b.source);\n let st = this.bindingState.get(b);\n // (Re)initialise on first sight or when the slot's source changed (studio edit): `appear`\n // ramps from 0 (entrance), every other source snaps to its current value.\n if (!st || st.source !== b.source) {\n st = { value: b.source === \"appear\" ? 0 : raw, source: b.source };\n this.bindingState.set(b, st);\n }\n st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);\n }\n\n /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */\n bindingValue(b: AnyBinding): number {\n return this.bindingState.get(b)?.value ?? 0;\n }\n\n /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */\n private rawSource(source: InteractionSource): number {\n switch (source) {\n case \"scroll\":\n return this.scroll;\n case \"hover\":\n return this.presence;\n case \"pointerX\":\n return (this.ndc.x + 1) * 0.5;\n case \"pointerY\":\n return (this.ndc.y + 1) * 0.5;\n case \"pointerSpeed\":\n return this.pointerSpeed;\n case \"press\":\n return this.press;\n case \"scrollVelocity\":\n return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);\n case \"appear\":\n return this.appearLatched ? 1 : 0;\n case \"tiltX\":\n return this.tiltX;\n case \"tiltY\":\n return this.tiltY;\n default:\n // custom:<name> — fed by setInput(name, value).\n return this.customInputs.get(source.slice(\"custom:\".length)) ?? 0;\n }\n }\n\n /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */\n private computeScroll(): number {\n const rect = this.container.getBoundingClientRect();\n const vh = window.innerHeight || document.documentElement.clientHeight || 1;\n return clamp01((vh - rect.top) / (vh + rect.height));\n }\n\n /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */\n sample(): InteractionSample {\n this.out.presence = this.presence;\n return this.out;\n }\n\n /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or\n * null if the wave hasn't been advanced by update() yet (treat as rest). */\n fieldFor(waveIdx: number): PointerField | null {\n return this.fields[waveIdx] ?? null;\n }\n\n /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The\n * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead\n * of buzzing at a constant rate whenever the cursor is merely present. */\n pointerFlux(): number {\n return this.pointerSpeed;\n }\n\n /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the\n * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */\n pointerVelocity(): THREE.Vector2 {\n return this.velNdc;\n }\n\n /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */\n setInput(name: string, value: number): void {\n if (typeof name !== \"string\" || !Number.isFinite(value)) return;\n this.customInputs.set(name, value);\n }\n\n /**\n * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /\n * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,\n * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must\n * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps\n * to its settled source so the one settled frame shows the final look.\n */\n settle(): void {\n this.presence = this.presenceTarget = 0;\n this.pointerPresent = false;\n this.press = this.pressTarget = 0;\n this.pointerSpeed = 0;\n this.tiltX = this.tiltY = 0.5; // a settled frame is the neutral pose, like the centred cursor\n this.velNdc.set(0, 0);\n this.ndc.set(0, 0);\n this.ndcTarget.set(0, 0);\n this.ndcPrev.set(0, 0);\n for (const f of this.fields) {\n f.ndc.set(0, 0);\n f.vel.set(0, 0);\n f.presence = 0;\n }\n for (const r of this.ripples) {\n r.age = 0;\n r.amp = 0;\n r.active = false;\n }\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = rawScroll;\n this.scrollVel = 0;\n this.appearLatched = true;\n const cfg = this.cfg();\n if (cfg) {\n this.bindingState.clear();\n const snap = (b: AnyBinding): void => {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n }\n\n /**\n * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving\n * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the\n * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a\n * manual scrub that must reflect the instant you move it — not on the next animation frame, which\n * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses\n * ALL input to rest for a paused still frame), this touches only the scroll signal.\n */\n snapScroll(): void {\n const raw = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = raw;\n this.scrollVel = 0; // a static scrub has no velocity\n const cfg = this.cfg();\n if (!cfg) return;\n const snap = (b: AnyBinding): void => {\n if (b.source === \"scroll\" || b.source === \"scrollVelocity\") {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n }\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n\n dispose(): void {\n const c = this.container;\n c.removeEventListener(\"pointerenter\", this.onPointerEnter);\n c.removeEventListener(\"pointermove\", this.onPointerMove);\n c.removeEventListener(\"pointerleave\", this.onPointerLeave);\n c.removeEventListener(\"pointercancel\", this.onPointerCancel);\n c.removeEventListener(\"pointerdown\", this.onPointerDown);\n c.removeEventListener(\"pointerup\", this.onPointerUp);\n this.tilt?.dispose();\n this.tilt = null;\n this.customInputs.clear();\n this.bindingState.clear();\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/renderer/interactionGates.ts
|
|
2
|
+
/** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */
|
|
3
|
+
function notDisabled(cfg) {
|
|
4
|
+
return cfg.interaction?.enabled !== false;
|
|
5
|
+
}
|
|
6
|
+
/** Whether a wave has a pointer field (hover effects, or a click ripple). */
|
|
7
|
+
function waveHasPointerField(w) {
|
|
8
|
+
const it = w.interaction;
|
|
9
|
+
return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);
|
|
10
|
+
}
|
|
11
|
+
/** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */
|
|
12
|
+
function wavePointerFxActive(cfg, w) {
|
|
13
|
+
return notDisabled(cfg) && waveHasPointerField(w);
|
|
14
|
+
}
|
|
15
|
+
/** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */
|
|
16
|
+
function waveRipplesActive(cfg, w) {
|
|
17
|
+
return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;
|
|
18
|
+
}
|
|
19
|
+
/** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */
|
|
20
|
+
function anyPointerFxActive(cfg) {
|
|
21
|
+
return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);
|
|
22
|
+
}
|
|
23
|
+
/** Whether the interaction layer should run at all (any wave interaction, or any scene binding).
|
|
24
|
+
* This is the predicate that decides whether the runtime chunk is ever fetched. */
|
|
25
|
+
function interactionActive(cfg) {
|
|
26
|
+
if (!notDisabled(cfg)) return false;
|
|
27
|
+
if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;
|
|
28
|
+
return cfg.waves.some((w) => {
|
|
29
|
+
const it = w.interaction;
|
|
30
|
+
return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** True when any binding in the list reads the orientation sensor. */
|
|
34
|
+
function anyTiltSource(bindings) {
|
|
35
|
+
if (!bindings) return false;
|
|
36
|
+
for (let i = 0; i < bindings.length; i++) {
|
|
37
|
+
const s = bindings[i].source;
|
|
38
|
+
if (s === "tiltX" || s === "tiltY") return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether anything in this scene reads the orientation sensor: a `tiltX` / `tiltY` binding anywhere,
|
|
44
|
+
* or the opt-in that lets tilt stand in for the cursor. A tilt BINDING is the switch — the
|
|
45
|
+
* `interaction.tilt` block is tuning, exactly as `pointerX` needs no "pointer" block — so a scene
|
|
46
|
+
* that never mentions tilt attaches no `deviceorientation` listener and touches no sensor. Indexed
|
|
47
|
+
* loops and no closures: unlike {@link interactionActive} this one is checked every frame.
|
|
48
|
+
*/
|
|
49
|
+
function tiltActive(cfg) {
|
|
50
|
+
if (!notDisabled(cfg)) return false;
|
|
51
|
+
if (cfg.interaction?.tilt?.pointer) return true;
|
|
52
|
+
if (anyTiltSource(cfg.interaction?.bindings)) return true;
|
|
53
|
+
for (let w = 0; w < cfg.waves.length; w++) if (anyTiltSource(cfg.waves[w].interaction?.bindings)) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { anyPointerFxActive, interactionActive, tiltActive, wavePointerFxActive, waveRipplesActive };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=interactionGates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interactionGates.js","names":[],"sources":["../../src/renderer/interactionGates.ts"],"sourcesContent":["// The synchronous half of the interactivity layer: pure config predicates plus the one constant the\n// shader is built against. Nothing here touches the DOM, holds state, or imports three.\n//\n// It is a SEPARATE MODULE for one reason — the tree-shaking boundary. The renderer needs these\n// answers synchronously (they decide which shader defines compile and whether the layer runs at\n// all), but the runtime behind them — the controller, its listeners, the applier tables, the tilt\n// sensor — is ~3.8 KB gzipped that a scene with no `interaction` block never executes. Keeping the\n// two in one file forced a static import of the whole layer into every bundle. Split, the renderer\n// imports only this (a few hundred bytes) and reaches interaction.ts through a dynamic import, so\n// the runtime is a chunk that is fetched only by pages that actually interact.\n//\n// The rule this file exists to enforce: NOTHING in the eager import graph may import\n// `./interaction` — reach it through `import(\"./interaction\")` instead.\nimport type { StudioConfig, WaveConfig } from \"../config/model\";\nimport type { SceneInteractionBinding, WaveInteractionBinding } from \"../config/model\";\n\n/** Click-ripple ring-buffer size. MUST match the `[4]` array sizes in shaders.ts (POINTER_RIPPLES). */\nexport const RIPPLE_SLOTS = 4;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */\nfunction notDisabled(cfg: StudioConfig): boolean {\n return cfg.interaction?.enabled !== false;\n}\n\n/** Whether a wave has a pointer field (hover effects, or a click ripple). */\nfunction waveHasPointerField(w: WaveConfig): boolean {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);\n}\n\n/** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */\nexport function wavePointerFxActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && waveHasPointerField(w);\n}\n\n/** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */\nexport function waveRipplesActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;\n}\n\n/** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */\nexport function anyPointerFxActive(cfg: StudioConfig): boolean {\n return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);\n}\n\n/** Whether the interaction layer should run at all (any wave interaction, or any scene binding).\n * This is the predicate that decides whether the runtime chunk is ever fetched. */\nexport function interactionActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;\n return cfg.waves.some((w) => {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);\n });\n}\n\n/** True when any binding in the list reads the orientation sensor. */\nfunction anyTiltSource(bindings: readonly AnyBinding[] | undefined): boolean {\n if (!bindings) return false;\n for (let i = 0; i < bindings.length; i++) {\n const s = bindings[i].source;\n if (s === \"tiltX\" || s === \"tiltY\") return true;\n }\n return false;\n}\n\n/**\n * Whether anything in this scene reads the orientation sensor: a `tiltX` / `tiltY` binding anywhere,\n * or the opt-in that lets tilt stand in for the cursor. A tilt BINDING is the switch — the\n * `interaction.tilt` block is tuning, exactly as `pointerX` needs no \"pointer\" block — so a scene\n * that never mentions tilt attaches no `deviceorientation` listener and touches no sensor. Indexed\n * loops and no closures: unlike {@link interactionActive} this one is checked every frame.\n */\nexport function tiltActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if (cfg.interaction?.tilt?.pointer) return true;\n if (anyTiltSource(cfg.interaction?.bindings)) return true;\n for (let w = 0; w < cfg.waves.length; w++) {\n if (anyTiltSource(cfg.waves[w].interaction?.bindings)) return true;\n }\n return false;\n}\n"],"mappings":";;AAsBA,SAAS,YAAY,KAA4B;CAC/C,OAAO,IAAI,aAAa,YAAY;AACtC;;AAGA,SAAS,oBAAoB,GAAwB;CACnD,MAAM,KAAK,EAAE;CACb,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,UAAU,GAAG,OAAO,UAAU,KAAK;AAC1D;;AAGA,SAAgB,oBAAoB,KAAmB,GAAwB;CAC7E,OAAO,YAAY,GAAG,KAAK,oBAAoB,CAAC;AAClD;;AAGA,SAAgB,kBAAkB,KAAmB,GAAwB;CAC3E,OAAO,YAAY,GAAG,MAAM,EAAE,aAAa,OAAO,UAAU,KAAK;AACnE;;AAGA,SAAgB,mBAAmB,KAA4B;CAC7D,OAAO,YAAY,GAAG,KAAK,IAAI,MAAM,KAAK,mBAAmB;AAC/D;;;AAIA,SAAgB,kBAAkB,KAA4B;CAC5D,IAAI,CAAC,YAAY,GAAG,GAAG,OAAO;CAC9B,KAAK,IAAI,aAAa,UAAU,UAAU,KAAK,GAAG,OAAO;CACzD,OAAO,IAAI,MAAM,MAAM,MAAM;EAC3B,MAAM,KAAK,EAAE;EACb,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,UAAU,GAAG,OAAO,UAAU,KAAK,MAAM,GAAG,UAAU,UAAU,KAAK;CAC5F,CAAC;AACH;;AAGA,SAAS,cAAc,UAAsD;CAC3E,IAAI,CAAC,UAAU,OAAO;CACtB,KAAK,IAAI,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;EACxC,MAAM,IAAI,SAAS,EAAE,CAAC;EACtB,IAAI,MAAM,WAAW,MAAM,SAAS,OAAO;CAC7C;CACA,OAAO;AACT;;;;;;;;AASA,SAAgB,WAAW,KAA4B;CACrD,IAAI,CAAC,YAAY,GAAG,GAAG,OAAO;CAC9B,IAAI,IAAI,aAAa,MAAM,SAAS,OAAO;CAC3C,IAAI,cAAc,IAAI,aAAa,QAAQ,GAAG,OAAO;CACrD,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,MAAM,QAAQ,KACpC,IAAI,cAAc,IAAI,MAAM,EAAE,CAAC,aAAa,QAAQ,GAAG,OAAO;CAEhE,OAAO;AACT"}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ParticlesConfig } from "../config/model.js";
|
|
2
1
|
import * as THREE from "three";
|
|
3
2
|
//#region src/renderer/particleField.d.ts
|
|
4
3
|
/**
|
|
@@ -19,63 +18,6 @@ interface ParticleFrame {
|
|
|
19
18
|
right: THREE.Vector3;
|
|
20
19
|
up: THREE.Vector3;
|
|
21
20
|
}
|
|
22
|
-
declare class ParticleField {
|
|
23
|
-
readonly points: THREE.Points;
|
|
24
|
-
private readonly geometry;
|
|
25
|
-
private readonly material;
|
|
26
|
-
/** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */
|
|
27
|
-
private sig;
|
|
28
|
-
/** Rasterized user artwork for shape "sprite", and the url it came from (so a repeat sync is a
|
|
29
|
-
* no-op). `spriteFailedUrl` latches a broken image so a bad url is not retried every frame. */
|
|
30
|
-
private sprite?;
|
|
31
|
-
private spriteUrl;
|
|
32
|
-
private spriteFailedUrl;
|
|
33
|
-
private disposed;
|
|
34
|
-
/** Defines this field owns (currently just PARTICLE_SPRITE), merged over the wave's in configure().
|
|
35
|
-
* Set only once a texture is actually bound, so the sampler never compiles without one. */
|
|
36
|
-
private ownDefines;
|
|
37
|
-
/** Called when a sprite finishes rasterizing, so a paused/settled renderer redraws with it. */
|
|
38
|
-
private readonly onReady?;
|
|
39
|
-
constructor(onReady?: () => void);
|
|
40
|
-
/** Reconcile to `cfg`: rebuild the seeded buffers if the layout signature changed, then push the
|
|
41
|
-
* frame-independent uniforms. `loopSeconds` is scene-level (passed in). Called from refresh(). */
|
|
42
|
-
sync(cfg: ParticlesConfig, loopSeconds: number): void;
|
|
43
|
-
/** Push the per-frame frame basis (the camera can move, so this runs every frame). */
|
|
44
|
-
frame(f: ParticleFrame, pixelRatio: number): void;
|
|
45
|
-
/** Bind the OWNING wave's shape AND cursor state: mirror its #defines + shape uniforms + pointer
|
|
46
|
-
* uniforms + world matrix, so the dust rides the same deform as the ribbon and reacts to the same
|
|
47
|
-
* pointer field. Recompiles the point program only when the define set changes — which is why the
|
|
48
|
-
* defines must come from CONFIG only (shapeDefines), never from live input. Called from refresh()
|
|
49
|
-
* each frame with the wave's live state. */
|
|
50
|
-
configure(shape: {
|
|
51
|
-
defines: Record<string, string>;
|
|
52
|
-
uniforms: Record<string, THREE.IUniform>;
|
|
53
|
-
matrixWorld: THREE.Matrix4;
|
|
54
|
-
speed: number;
|
|
55
|
-
seed: number;
|
|
56
|
-
}): void;
|
|
57
|
-
/** Reconcile the sprite texture to `url` ("" = none). Cheap and idempotent: a repeat call with the
|
|
58
|
-
* same url does nothing, and a url that already failed is never retried. */
|
|
59
|
-
private syncSprite;
|
|
60
|
-
/** Drop the current sprite and fall back to the procedural shapes (which is what `uShape` still
|
|
61
|
-
* holds, so a field mid-load or with a broken image draws "glitter" rather than nothing). */
|
|
62
|
-
private clearSprite;
|
|
63
|
-
/**
|
|
64
|
-
* Rasterize `url` into a square {@link SPRITE_PX} texture and bind it.
|
|
65
|
-
*
|
|
66
|
-
* Deliberately the BACKGROUND-image pattern (load → apply → ask for a redraw) rather than
|
|
67
|
-
* loadPaletteImage's fire-and-forget TextureLoader: a thumbnail or poster snapshotted while the
|
|
68
|
-
* texture was still in flight would capture blank dust — the same class of bug that made
|
|
69
|
-
* image-driven preset thumbnails render empty.
|
|
70
|
-
*/
|
|
71
|
-
private loadSprite;
|
|
72
|
-
/** Copy one uniform across from the owning wave: numbers by value, vectors/matrices in place, and
|
|
73
|
-
* arrays (the ripple slots) by reference — the wave owns those and mutates them in place. */
|
|
74
|
-
private mirror;
|
|
75
|
-
/** Advance the field to scene time `t` (= the same `t` the waves get). */
|
|
76
|
-
setTime(t: number): void;
|
|
77
|
-
dispose(): void;
|
|
78
|
-
}
|
|
79
21
|
//#endregion
|
|
80
|
-
export {
|
|
22
|
+
export { ParticleFrame };
|
|
81
23
|
//# sourceMappingURL=particleField.d.ts.map
|
|
@@ -115,8 +115,51 @@ function buildParticleAttributes(count, seed, edgeBias = 1, bias = 0) {
|
|
|
115
115
|
aUv
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Rasterize `url` into a square {@link SPRITE_PX} texture.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately the BACKGROUND-image pattern (load -> apply -> ask for a redraw) rather than a
|
|
122
|
+
* fire-and-forget TextureLoader: a thumbnail or poster snapshotted while the texture was still in
|
|
123
|
+
* flight would capture blank dust — the same class of bug that made image-driven preset thumbnails
|
|
124
|
+
* render empty.
|
|
125
|
+
*
|
|
126
|
+
* Shared by both backends' particle fields, so the CORS rule, the letterboxing and the mipmap
|
|
127
|
+
* settings have one home.
|
|
128
|
+
*/
|
|
129
|
+
function loadSpriteTexture(url, onLoaded, onFailed) {
|
|
130
|
+
const img = new Image();
|
|
131
|
+
img.decoding = "async";
|
|
132
|
+
if (!url.startsWith("data:") && !url.startsWith("blob:")) img.crossOrigin = "anonymous";
|
|
133
|
+
img.addEventListener("load", () => {
|
|
134
|
+
const canvas = document.createElement("canvas");
|
|
135
|
+
canvas.width = SPRITE_PX;
|
|
136
|
+
canvas.height = SPRITE_PX;
|
|
137
|
+
const ctx = canvas.getContext("2d");
|
|
138
|
+
if (!ctx) return;
|
|
139
|
+
const iw = img.naturalWidth || SPRITE_PX;
|
|
140
|
+
const ih = img.naturalHeight || SPRITE_PX;
|
|
141
|
+
const fit = Math.min(SPRITE_PX / iw, SPRITE_PX / ih);
|
|
142
|
+
const w = iw * fit;
|
|
143
|
+
const h = ih * fit;
|
|
144
|
+
ctx.drawImage(img, (SPRITE_PX - w) / 2, (SPRITE_PX - h) / 2, w, h);
|
|
145
|
+
const tex = new THREE.CanvasTexture(canvas);
|
|
146
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
147
|
+
tex.generateMipmaps = true;
|
|
148
|
+
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
|
149
|
+
tex.magFilter = THREE.LinearFilter;
|
|
150
|
+
tex.wrapS = THREE.ClampToEdgeWrapping;
|
|
151
|
+
tex.wrapT = THREE.ClampToEdgeWrapping;
|
|
152
|
+
onLoaded(tex);
|
|
153
|
+
}, { once: true });
|
|
154
|
+
img.addEventListener("error", onFailed, { once: true });
|
|
155
|
+
img.src = url;
|
|
156
|
+
}
|
|
118
157
|
var ParticleField = class {
|
|
119
158
|
points;
|
|
159
|
+
/** The scene node, named the same on both backends (the TSL field is a Sprite, not Points). */
|
|
160
|
+
get object() {
|
|
161
|
+
return this.points;
|
|
162
|
+
}
|
|
120
163
|
geometry = new THREE.BufferGeometry();
|
|
121
164
|
material;
|
|
122
165
|
/** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */
|
|
@@ -234,8 +277,8 @@ var ParticleField = class {
|
|
|
234
277
|
u.uShape.value = SHAPE_INDEX[cfg.shape ?? "glitter"] ?? 0;
|
|
235
278
|
u.uPartShove.value = cfg.pointerShove ?? 1;
|
|
236
279
|
this.syncSprite(cfg.shape === "sprite" ? cfg.spriteUrl ?? "" : "");
|
|
237
|
-
setLinear(u.uColor.value, cfg.color ??
|
|
238
|
-
setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ??
|
|
280
|
+
setLinear(u.uColor.value, cfg.color ?? "#ffcf8a");
|
|
281
|
+
setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? "#ffcf8a");
|
|
239
282
|
}
|
|
240
283
|
/** Push the per-frame frame basis (the camera can move, so this runs every frame). */
|
|
241
284
|
frame(f, pixelRatio) {
|
|
@@ -296,39 +339,16 @@ var ParticleField = class {
|
|
|
296
339
|
* image-driven preset thumbnails render empty.
|
|
297
340
|
*/
|
|
298
341
|
loadSprite(url) {
|
|
299
|
-
|
|
300
|
-
img.decoding = "async";
|
|
301
|
-
if (!url.startsWith("data:") && !url.startsWith("blob:")) img.crossOrigin = "anonymous";
|
|
302
|
-
img.addEventListener("load", () => {
|
|
342
|
+
loadSpriteTexture(url, (tex) => {
|
|
303
343
|
if (this.disposed || this.spriteUrl !== url) return;
|
|
304
|
-
const canvas = document.createElement("canvas");
|
|
305
|
-
canvas.width = SPRITE_PX;
|
|
306
|
-
canvas.height = SPRITE_PX;
|
|
307
|
-
const ctx = canvas.getContext("2d");
|
|
308
|
-
if (!ctx) return;
|
|
309
|
-
const iw = img.naturalWidth || SPRITE_PX;
|
|
310
|
-
const ih = img.naturalHeight || SPRITE_PX;
|
|
311
|
-
const fit = Math.min(SPRITE_PX / iw, SPRITE_PX / ih);
|
|
312
|
-
const w = iw * fit;
|
|
313
|
-
const h = ih * fit;
|
|
314
|
-
ctx.drawImage(img, (SPRITE_PX - w) / 2, (SPRITE_PX - h) / 2, w, h);
|
|
315
|
-
const tex = new THREE.CanvasTexture(canvas);
|
|
316
|
-
tex.colorSpace = THREE.SRGBColorSpace;
|
|
317
|
-
tex.generateMipmaps = true;
|
|
318
|
-
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
|
319
|
-
tex.magFilter = THREE.LinearFilter;
|
|
320
|
-
tex.wrapS = THREE.ClampToEdgeWrapping;
|
|
321
|
-
tex.wrapT = THREE.ClampToEdgeWrapping;
|
|
322
344
|
this.sprite = tex;
|
|
323
345
|
this.material.uniforms.uSprite.value = tex;
|
|
324
346
|
this.ownDefines = { PARTICLE_SPRITE: "" };
|
|
325
347
|
this.material.needsUpdate = true;
|
|
326
348
|
this.onReady?.();
|
|
327
|
-
},
|
|
328
|
-
img.addEventListener("error", () => {
|
|
349
|
+
}, () => {
|
|
329
350
|
this.spriteFailedUrl = url;
|
|
330
|
-
}
|
|
331
|
-
img.src = url;
|
|
351
|
+
});
|
|
332
352
|
}
|
|
333
353
|
/** Copy one uniform across from the owning wave: numbers by value, vectors/matrices in place, and
|
|
334
354
|
* arrays (the ripple slots) by reference — the wave owns those and mutates them in place. */
|
|
@@ -352,6 +372,6 @@ var ParticleField = class {
|
|
|
352
372
|
}
|
|
353
373
|
};
|
|
354
374
|
//#endregion
|
|
355
|
-
export { ParticleField, buildParticleAttributes };
|
|
375
|
+
export { DEFAULT_COLOR, ParticleField, SHAPE_INDEX, buildParticleAttributes, loadSpriteTexture, setLinear };
|
|
356
376
|
|
|
357
377
|
//# sourceMappingURL=particleField.js.map
|
|
@@ -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 \"./interaction\";\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/**\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). */\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/** 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\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 /** 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 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 // The config may have moved on (or the field been disposed) while this was decoding.\n if (this.disposed || this.spriteUrl !== url) return;\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 (gl_PointCoord spans 0..1 on both\n // axes), so non-square art has to be letterboxed or it draws stretched. An SVG with no\n // intrinsic size reports 0 in some 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, and gl_PointCoord's\n // derivatives across a point sprite are well defined, so the GPU picks a sane level.\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 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 { once: true },\n );\n // Latch the failure so a broken url is not re-requested on every sync.\n img.addEventListener(\n \"error\",\n () => {\n this.spriteFailedUrl = url;\n },\n { once: true },\n );\n img.src = url;\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};\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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"}
|