@wave3d/core 0.5.0 → 0.6.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.
@@ -1,5 +1,4 @@
1
1
  import * as THREE from "three";
2
-
3
2
  //#region src/renderer/heroPalette.d.ts
4
3
  /** Draw the hero LUT onto a small canvas (for panel swatch previews). */
5
4
  declare function buildHeroPaletteCanvas(): HTMLCanvasElement;
@@ -1,9 +1,7 @@
1
1
  import { SceneInteractionBinding, StudioConfig, WaveInteractionBinding } from "../config/model.js";
2
2
  import * as THREE from "three";
3
-
4
3
  //#region src/renderer/interaction.d.ts
5
4
  type AnyBinding = WaveInteractionBinding | SceneInteractionBinding;
6
- /** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */
7
5
  interface RippleSlot {
8
6
  origin: THREE.Vector2;
9
7
  age: number;
@@ -101,6 +101,15 @@ const WAVE_APPLIERS = {
101
101
  iridescence: waveApplier((w) => w.iridescence ?? 0, (v, a) => {
102
102
  a.u.uIridescence.value = v;
103
103
  }),
104
+ helixPhase: waveApplier((w) => w.helixPhase ?? 0, (v, a) => {
105
+ a.u.uHelixPhase.value = v;
106
+ }),
107
+ helixTurns: waveApplier((w) => w.helixTurns ?? 0, (v, a) => {
108
+ a.u.uHelixTurns.value = v;
109
+ }),
110
+ helixRadius: waveApplier((w) => w.helixRadius ?? 0, (v, a) => {
111
+ a.u.uHelixRadius.value = v;
112
+ }),
104
113
  positionX: waveApplier((w) => w.position.x, (v, a) => {
105
114
  a.mesh.position.x = v;
106
115
  }),
@@ -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 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 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,6 +1,5 @@
1
1
  import { BackgroundImageFit, BasicGradientType, ColorStop, MeshGradientPoint } from "../config/model.js";
2
2
  import * as THREE from "three";
3
-
4
3
  //#region src/renderer/palette.d.ts
5
4
  interface PaletteTextureOptions {
6
5
  stops: ColorStop[];
@@ -166,6 +166,15 @@ uniform float uDetailFreq, uDetailAmount; // 2nd displacement octave (only read
166
166
  uniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;
167
167
  uniform float uLoopSeconds; // seamless-loop period (only read under LOOP_MOTION)
168
168
 
169
+ // Helix (optional). Behind HELIX so a wave without one compiles the exact same program — same
170
+ // byte-identity contract as the pointer block below.
171
+ #ifdef HELIX
172
+ uniform float uHelixTurns; // full turns from one end of the ribbon to the other
173
+ uniform float uHelixRadius; // orbit radius: carries the whole ribbon around the axis
174
+ uniform float uHelixRoll; // cross-section roll, as a fraction of the turns (1 = rigid ladder)
175
+ uniform float uHelixPhase; // degrees
176
+ #endif
177
+
169
178
  varying vec2 vUv;
170
179
  varying vec3 vWorldPos;
171
180
  varying vec3 vViewDir;
@@ -254,6 +263,28 @@ void main(){
254
263
  #endif
255
264
  #endif
256
265
 
266
+ #ifdef HELIX
267
+ // Helix — the one shape the three twists below cannot reach. Their angle is freq * expStep(uv),
268
+ // a MONOTONE falloff, so it can only ramp once; this one is periodic in uv.y (the length), so
269
+ // uHelixTurns full turns land evenly from end to end. Runs AFTER the displacement so the noise
270
+ // above still samples the undeformed pos.x/pos.z (byte-identical sampling), and BEFORE the twist
271
+ // so the two compose.
272
+ // roll rolls the ribbon's own cross-section about the axis in step with the sweep, swinging
273
+ // its two long edges onto opposite sides — one wave becomes a ladder whose edges are
274
+ // both strands (pair with the wireframe theme's rungs for the rungs between them).
275
+ // radius carries the whole ribbon around the axis instead, orientation intact — a narrow ribbon
276
+ // then reads as ONE strand, and a second wave at phase+180 is the other.
277
+ float hAng = 6.28318530718 * uHelixTurns * uv.y + radians(uHelixPhase);
278
+ // Roll about the ribbon's width centre, not the origin — see RIBBON_Z_CENTER in WaveGeometry.
279
+ float rollA = hAng * uHelixRoll;
280
+ float rollC = cos(rollA), rollS = sin(rollA);
281
+ vec2 rel = vec2(pos.y, pos.z - ${(-8).toFixed(1)});
282
+ pos.y = rel.x * rollC - rel.y * rollS;
283
+ pos.z = ${(-8).toFixed(1)} + rel.x * rollS + rel.y * rollC;
284
+ pos.y += uHelixRadius * cos(hAng);
285
+ pos.z += uHelixRadius * sin(hAng);
286
+ #endif
287
+
257
288
  // The X-twist frequency feeding rotB. Two modes: by default uTwFreqX is used
258
289
  // directly; the variant (used by the Wave 4 preset) modulates it with
259
290
  // simplex noise indexed along the ribbon (uv.y) so the twist breathes over time.
@@ -580,6 +611,11 @@ uniform float uLineAmount; // default 425
580
611
  uniform float uLineThickness; // default 1
581
612
  uniform float uLineDerivativePower; // default 0.95
582
613
  uniform float uMaxWidth; // default 1232
614
+ // Cross-wise rungs (optional) — behind RUNGS so a wave without them compiles the same program.
615
+ #ifdef RUNGS
616
+ uniform float uRungAmount; // frequency across the ribbon (rungs ≈ amount / π)
617
+ uniform float uRungThickness; // rung width in pixels
618
+ #endif
583
619
  uniform vec3 uClearColor; // = page background colour (shown between the lines)
584
620
 
585
621
  varying vec2 vUv;
@@ -611,6 +647,16 @@ void main(){
611
647
  float a = abs(sin(vUv.x * uLineAmount));
612
648
  a = smoothstep(lineThickness, 0.0, a);
613
649
 
650
+ #ifdef RUNGS
651
+ // Rungs: the same carve at constant uv.y instead of uv.x, so this family runs ACROSS the ribbon
652
+ // where the one above runs along it — together they read as a ladder. Width comes from fwidth()
653
+ // rather than the lengthwise term's dFdy(vUv).x, which is the derivative of the wrong axis for
654
+ // this direction: |sin| climbs by ~uRungAmount·fwidth(vUv.y) per pixel, so scaling by that keeps
655
+ // a rung uRungThickness pixels wide at any zoom or ribbon scale.
656
+ float rung = abs(sin(vUv.y * uRungAmount));
657
+ a = max(a, smoothstep(uRungThickness * uRungAmount * fwidth(vUv.y), 0.0, rung));
658
+ #endif
659
+
614
660
  // Depth fade: the wave recedes into the background colour with depth. Watch the
615
661
  // argument order: clamp(0.0, 1.0, z*6) is a swapped-args trap — it clamps the
616
662
  // constant 0.0 into [1.0, z*6], i.e. min(1.0, z*6), which (with our ortho clip.z
@@ -1 +1 @@
1
- {"version":3,"file":"shaders.js","names":[],"sources":["../../src/renderer/shaders.ts"],"sourcesContent":["import { MAX_COLORS, MAX_LIGHTS, MAX_MESH_POINTS, MAX_NOISE_BANDS } from \"../config/model\";\n\n/**\n * The wave shaders. Vertex: a flat plane is Y-displaced by simplex noise, then\n * twisted by three axis-rotations `freq * expStep(uv, power)` where\n * `expStep(x,n) = exp2(-exp2(n)*pow(x,n))` is a falloff (rotation concentrated at\n * the uv=0 edge), with diagonal axes + an animated X wobble. Fragment: uses NO\n * normal-based lighting — \"thickness\" comes from `crease`, a foreshorten/fold\n * detector built from `dFdy(uv)`, used to lift flat areas toward white\n * (`col += (1-crease)*0.25`) and to localise the striations. Striations are subtle\n * high-frequency simplex noise ADDED to the colour, colour-matched via (1-blue)\n * and end-weighted via a parabola — so they blend rather than form hard lines.\n * Our additions: gradient stops/types for colour, and an optional additive light\n * layer (kept gentle so the default look is preserved).\n */\n\n// Noise function: xxHash-seeded unit-vector gradients + a Gustavson simplex. It uses\n// GLSL ES 3.00 integer ops (floatBitsToUint, unsigned bit-shifts) — available with no\n// glslVersion change because three compiles non-raw ShaderMaterials as \"#version 300 es\"\n// already. `hash` returns a vec2 here — the cheap grain hash in the fragment is named\n// `grainHash` to avoid clashing with it.\nconst simplex2d = /* glsl */ `\nfloat xxhash(vec2 x){\n uvec2 t = floatBitsToUint(x);\n uint h = 0xc2b2ae3du * t.x + 0x165667b9u;\n h = (h << 17u | h >> 15u) * 0x27d4eb2fu;\n h += 0xc2b2ae3du * t.y;\n h = (h << 17u | h >> 15u) * 0x27d4eb2fu;\n h ^= h >> 15u;\n h *= 0x85ebca77u;\n h ^= h >> 13u;\n h *= 0xc2b2ae3du;\n h ^= h >> 16u;\n return uintBitsToFloat(h >> 9u | 0x3f800000u) - 1.0;\n}\nvec2 hash(vec2 x){\n float k = 6.283185307 * xxhash(x);\n return vec2(cos(k), sin(k));\n}\nfloat simplexNoise(in vec2 p){\n const float K1 = 0.366025404; // (sqrt(3)-1)/2\n const float K2 = 0.211324865; // (3-sqrt(3))/6\n vec2 i = floor(p + (p.x + p.y) * K1);\n vec2 a = p - i + (i.x + i.y) * K2;\n float m = step(a.y, a.x);\n vec2 o = vec2(m, 1.0 - m);\n vec2 b = a - o + K2;\n vec2 c = a - 1.0 + 2.0 * K2;\n vec3 h = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);\n vec3 n = h * h * h * vec3(dot(a, hash(i + 0.0)), dot(b, hash(i + o)), dot(c, hash(i + 1.0)));\n return dot(n, vec3(32.99)); // analytic factor (= 2916*sqrt(2)/125)\n}\n`;\n\n// Uniforms shared by BOTH fragment shaders (solid + wireframe line): the palette/gradient\n// inputs and the colour-grade knobs. Each shader declares its theme-specific uniforms beside\n// this block. Requires MAX_COLORS / MAX_MESH_POINTS #defines.\nconst colorUniforms = /* glsl */ `\nuniform vec3 uColors[MAX_COLORS];\nuniform float uColorPos[MAX_COLORS];\nuniform int uColorCount;\nuniform int uGradType;\nuniform float uGradAngle;\nuniform float uGradShift;\nuniform vec2 uMeshPointPos[MAX_MESH_POINTS];\nuniform vec3 uMeshPointColor[MAX_MESH_POINTS];\nuniform float uMeshPointInfluence[MAX_MESH_POINTS];\nuniform int uMeshPointCount;\nuniform float uMeshSoftness;\nuniform sampler2D uPalette; // baked 2D palette texture\nuniform float uUsePalette; // >0.5 = sample the texture; else procedural grad()\nuniform float uPaletteRaw; // >0.5 = sample palette by raw (uv.x,uv.y), not gradCoord\nuniform vec2 uPaletteScale;\nuniform vec2 uPaletteOffset;\nuniform float uPaletteRotation;\nuniform float uHueShift;\nuniform float uContrast;\nuniform float uSaturation;\nuniform float uOpacity;\nuniform float uSquared; // 1 = square the output colour (the deep \"squared\" hero look)\n`;\n\n// Colour helpers + the palette/gradient sampler shared by both fragment shaders.\n// Interpolate AFTER ${\"simplex2d\"} and ${\"colorUniforms\"} (gradCoord needs both) and a PI define.\nconst colorFns = /* glsl */ `\nvec3 contrastFn(vec3 v, float a){ return (v - 0.5) * a + 0.5; }\nvec3 desaturate(vec3 color, float factor){\n vec3 gray = vec3(dot(vec3(0.299, 0.587, 0.114), color));\n return mix(color, gray, factor);\n}\nvec3 hueShift(vec3 color, float shift){\n vec3 g = vec3(0.57735);\n vec3 proj = g * dot(g, color);\n vec3 U = color - proj;\n vec3 W = cross(g, U);\n return U * cos(shift) + W * sin(shift) + proj;\n}\n\n// Our gradient: interpolate stops by their positions (uColorPos sorted ascending).\nvec3 grad(float u){\n u = clamp(u, 0.0, 1.0);\n vec3 col = uColors[0];\n for (int i = 0; i < MAX_COLORS - 1; i++){\n if (i >= uColorCount - 1) break;\n float p0 = uColorPos[i];\n float p1 = uColorPos[i + 1];\n if (u >= p0){\n float t = clamp((u - p0) / max(p1 - p0, 1e-5), 0.0, 1.0);\n col = mix(uColors[i], uColors[i + 1], t);\n }\n }\n return col;\n}\n\n// iOS-style 2D colour field. Each control point contributes an inverse-distance\n// weight; normalising the sum fills the whole surface without dark seams.\nvec3 meshGradient(vec2 uv){\n vec3 colorSum = vec3(0.0);\n float weightSum = 0.0;\n float exponent = mix(4.8, 1.35, clamp(uMeshSoftness, 0.0, 1.0));\n for (int i = 0; i < MAX_MESH_POINTS; i++){\n if (i >= uMeshPointCount) break;\n float influence = max(uMeshPointInfluence[i], 0.05);\n float distanceFromPoint = length(uv - uMeshPointPos[i]) / influence;\n float weight = 1.0 / (pow(max(distanceFromPoint, 0.012), exponent) + 0.002);\n colorSum += uMeshPointColor[i] * weight;\n weightSum += weight;\n }\n return colorSum / max(weightSum, 0.0001);\n}\n\n// Map a surface uv to the 0–1 gradient coordinate per gradient type. uGradShift\n// adds a low-frequency simplex warp so the colour varies in 2D (along the length\n// as well as across the width) — a 2D palette feel instead\n// of flat 1-D bands.\nfloat gradCoord(vec2 uv){\n float warp = uGradShift * simplexNoise(uv * 1.6 + 4.0);\n if (uGradType == 1){ return clamp(length(uv - 0.5) * 2.0 + warp, 0.0, 1.0); } // radial\n if (uGradType == 2){ return fract(atan(uv.y - 0.5, uv.x - 0.5) / (2.0 * PI) + 0.5 + warp); } // conic\n vec2 dir = vec2(sin(uGradAngle), cos(uGradAngle)); // linear, angled\n return clamp(dot(uv - 0.5, dir) + 0.5 + warp, 0.0, 1.0);\n}\n\n// One base-colour sample for the whole surface: rotate/scale/offset the raw-palette uv,\n// then pick the mesh field / baked 2D texture / procedural stops by mode. The raw palette\n// is sampled by (uv.x, uv.y) directly; the stops-generated texture is sampled via\n// gradCoord so its angle/type/warp still apply.\nvec3 waveBaseColor(vec2 uv){\n float gc = gradCoord(uv);\n vec2 mediaUv = uv - 0.5;\n float mediaCos = cos(uPaletteRotation);\n float mediaSin = sin(uPaletteRotation);\n mediaUv = vec2(\n mediaCos * mediaUv.x + mediaSin * mediaUv.y,\n -mediaSin * mediaUv.x + mediaCos * mediaUv.y\n );\n mediaUv = mediaUv * uPaletteScale + 0.5 + uPaletteOffset;\n vec2 puv = uPaletteRaw > 0.5\n ? clamp(mediaUv, 0.0, 1.0)\n : vec2(gc, clamp(uv.y, 0.0, 1.0));\n return uGradType == 3\n ? meshGradient(uv)\n : (uUsePalette > 0.5 ? texture2D(uPalette, puv).rgb : grad(gc));\n}\n\n// The shared colour grade: contrast → desaturate → hue rotate (degrees).\nvec3 applyColorGrade(vec3 c){\n c = contrastFn(c, uContrast);\n c = desaturate(c, 1.0 - uSaturation);\n return hueShift(c, radians(uHueShift));\n}\n`;\n\nexport const vertexShader = /* glsl */ `\n${simplex2d}\n\nuniform float uTime, uSpeed, uSeed;\nuniform float uDispFreqX, uDispFreqZ, uDispAmount;\nuniform float uDetailFreq, uDetailAmount; // 2nd displacement octave (only read under DETAIL_OCTAVE)\nuniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;\nuniform float uLoopSeconds; // seamless-loop period (only read under LOOP_MOTION)\n\nvarying vec2 vUv;\nvarying vec3 vWorldPos;\nvarying vec3 vViewDir;\nvarying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade\n\n// Pointer field (optional, additive). ALL declarations here sit behind POINTER_FX so a wave with\n// no interaction config compiles the exact same program (JS-side uniform entries are always present\n// — see makeUniforms — but three only uploads uniforms the compiled program actually declares).\n#ifdef POINTER_FX\nuniform vec2 uPointer; // smoothed pointer, NDC (-1..1)\nuniform float uPointerActive; // presence ramp 0..1 × per-wave influence\nuniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)\nuniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)\nuniform float uPointerAgitate;\nuniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)\nuniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)\nuniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)\n// Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the\n// ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).\nuniform float uShapeFlow;\nvarying float vPointerFall; // falloff × presence — consumed by both fragment themes\n#ifdef POINTER_RIPPLES\nuniform vec2 uRippleOrigin[4]; // NDC\nuniform float uRippleAge[4]; // seconds since spawn (CPU-computed)\nuniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)\nuniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)\nconst float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward\nconst float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)\nconst float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)\nconst float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame\n#endif\n#endif\n\n// expStep: a falloff from 1 (at x=0) toward 0, sharpness set by n. The\n// max() guards pow(0, n) (= Infinity → NaN) so negative n is safe — negative n\n// just concentrates the twist toward the OTHER end instead.\nfloat expStep(float x, float n){ return exp2(-exp2(n) * pow(max(x, 1.0e-3), n)); }\n\n// rotationMatrix (mat4), used row-vector style: pos = (vec4(pos,1) * R).xyz\nmat4 rotationMatrix(vec3 axis, float angle){\n axis = normalize(axis);\n float s = sin(angle), c = cos(angle), oc = 1.0 - c;\n return mat4(\n oc*axis.x*axis.x + c, oc*axis.x*axis.y - axis.z*s, oc*axis.z*axis.x + axis.y*s, 0.0,\n oc*axis.x*axis.y + axis.z*s, oc*axis.y*axis.y + c, oc*axis.y*axis.z - axis.x*s, 0.0,\n oc*axis.z*axis.x - axis.y*s, oc*axis.y*axis.z + axis.x*s, oc*axis.z*axis.z + c, 0.0,\n 0.0, 0.0, 0.0, 1.0\n );\n}\n\nvoid main(){\n vUv = uv;\n#ifndef LOOP_MOTION\n float t = uTime * uSpeed + uSeed;\n#endif\n\n#ifdef LOOP_MOTION\n // Seamless loop: rather than scrolling the noise field linearly by t (which never repeats),\n // sample it on a circle of radius loopR at angle loopTheta — exactly periodic with period\n // uLoopSeconds. The tangential speed loopR·dθ/dt equals uSpeed, so the looped motion advances\n // at the same rate as the linear drift, just curved into a closed orbit (it orbits rather than\n // drifts — the trade-off for a seamless loop, hence opt-in). uSeed offsets the phase so stacked\n // waves keep their relative motion while sharing the single period.\n float loopTheta = uTime * (6.28318530718 / uLoopSeconds) + uSeed;\n float loopR = uSpeed * uLoopSeconds * 0.159154943092; // = uSpeed·uLoopSeconds / (2π)\n vec2 loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));\n#endif\n\n // The base geometry is already a baked hairpin fold. On top of it we deform the\n // vertices: a displacement lifts Y by simplex noise of the (x,z) position, then\n // three axis-rotations twist the strip.\n vec3 pos = position;\n#ifdef LOOP_MOTION\n pos.y += uDispAmount * simplexNoise(vec2(pos.x * uDispFreqX, pos.z * uDispFreqZ) + loopOff);\n#else\n pos.y += uDispAmount * simplexNoise(vec2(pos.x * uDispFreqX + t, pos.z * uDispFreqZ + t));\n#endif\n#ifdef DETAIL_OCTAVE\n // A second, finer octave riding on the broad swell — fine ripples on top of the big shape, a\n // shape vocabulary single-octave displacement can't reach. Shares the loop orbit so it stays\n // periodic when looping.\n#ifdef LOOP_MOTION\n pos.y += uDetailAmount * simplexNoise(vec2(pos.x * uDetailFreq, pos.z * uDetailFreq) + loopOff);\n#else\n pos.y += uDetailAmount * simplexNoise(vec2(pos.x * uDetailFreq + t, pos.z * uDetailFreq + t));\n#endif\n#endif\n\n // The X-twist frequency feeding rotB. Two modes: by default uTwFreqX is used\n // directly; the variant (used by the Wave 4 preset) modulates it with\n // simplex noise indexed along the ribbon (uv.y) so the twist breathes over time.\n // We gate the wobble with a #define so the compiled program is unchanged when off.\n float twistXFreq = uTwFreqX;\n#ifdef TWIST_MOTION\n#ifdef LOOP_MOTION\n float twistXNoise = simplexNoise(vec2(vUv.y * 2.0, 0.0) + loopOff);\n#else\n float twistXNoise = simplexNoise(vec2(vUv.y * 2.0, t));\n#endif\n twistXFreq = uTwFreqX - twistXNoise * 0.1;\n#endif\n\n // Three-axis twist: expStep falloff sets how\n // sharply each rotation concentrates toward an edge. rotA keys off uv.x, rotB/rotC\n // off uv.y; axes (0.5,0,0.5) and (0,0.5,0.5) are normalised inside rotationMatrix.\n mat4 rotA = rotationMatrix(vec3(0.5, 0.0, 0.5), uTwFreqY * expStep(uv.x, uTwPowY));\n mat4 rotB = rotationMatrix(vec3(0.0, 0.5, 0.5), twistXFreq * expStep(uv.y, uTwPowX));\n mat4 rotC = rotationMatrix(vec3(0.5, 0.0, 0.5), uTwFreqZ * expStep(uv.y, uTwPowZ));\n pos = (vec4(pos, 1.0) * rotA).xyz;\n pos = (vec4(pos, 1.0) * rotB).xyz;\n pos = (vec4(pos, 1.0) * rotC).xyz;\n\n#ifdef POINTER_FX\n // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space\n // falloff around the smoothed cursor — a circle at uShapeFlow 0, stretched along the ribbon as it\n // rises. Everything here is ADDITIVE and fenced, so the shared path above/below is untouched and\n // byte-identical when POINTER_FX is off.\n // Shared clip-space transform, computed once and reused for the cursor metric and the ribbon\n // tangent (the compiler is not guaranteed to CSE the triple product otherwise). Associativity is\n // unchanged, so preClip is bit-for-bit what the plain P*V*M*v product produced.\n mat4 mvp = projectionMatrix * viewMatrix * modelMatrix;\n vec4 preClip = mvp * vec4(pos, 1.0);\n // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.\n vec2 ndcHere = preClip.xy / max(preClip.w, 1.0e-6);\n vec2 dp = (ndcHere - uPointer) * vec2(uPointerAspect, 1.0);\n // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the\n // ribbon and stays tight across it — the \"flows with the material\" feel, per-vertex (so it follows\n // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)\n // carried through the SAME twist as the surface. The camera is orthographic (affine, w=1), so the\n // axis's screen image is the linear map of the DIRECTION (w=0): one mat·dir, no second\n // point-projection and no perspective divide. (A true per-pixel uv would need GPU picking — the\n // visible surface is shader-displaced, so a CPU raycast of the base geometry misses.)\n if (uShapeFlow > 0.0) {\n vec3 tangentLocal = (((vec4(1.0, 0.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;\n vec2 tang = (mvp * vec4(tangentLocal, 0.0)).xy * vec2(uPointerAspect, 1.0);\n float tl = length(tang);\n if (tl > 1.0e-6) {\n tang /= tl;\n vec2 nrm = vec2(-tang.y, tang.x);\n dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length\n }\n }\n float fall = smoothstep(uPointerRadius, 0.0, length(dp));\n vPointerFall = fall * uPointerActive;\n // Displacement axis = local +Y carried through the SAME three twist rotations as pos (row-vector\n // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.\n vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;\n // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which\n // would force restructuring the shared path). Loop-safe under both time variants.\n#ifdef LOOP_MOTION\n float disp = uPointerAgitate * vPointerFall\n * simplexNoise(vec2(pos.x * uDispFreqX * 3.0, pos.z * uDispFreqZ * 3.0) + loopOff * 4.0);\n#else\n float disp = uPointerAgitate * vPointerFall\n * simplexNoise(vec2(pos.x * uDispFreqX * 3.0 + t * 4.0, pos.z * uDispFreqZ * 3.0));\n#endif\n // Membrane push/pull: a smooth dome (vPointerFall is the falloff) that swells toward you (+ repel)\n // or dents away (− attract) at the cursor, riding along with the sprung field.\n disp += uPointerPush * vPointerFall;\n // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points\n // from cursor to vertex; \"behind\" is how far the vertex sits opposite the velocity (0 ahead → 1 a\n // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.\n vec2 velC = uPointerVel * vec2(uPointerAspect, 1.0);\n float wakeSpeed = length(velC);\n if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {\n float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);\n disp -= uPointerWake * vPointerFall * behind * smoothstep(0.05, 0.6, wakeSpeed);\n }\n#ifdef POINTER_RIPPLES\n for (int i = 0; i < 4; i++) {\n if (uRippleAmp[i] > 0.0) {\n float rd = length((preClip.xy / max(preClip.w, 1.0e-6) - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));\n // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on\n // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),\n // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp\n // envelope fades the whole packet over its lifetime; reach fades it as the crest leaves frame.\n float front = uRippleAge[i] * RIPPLE_WAVE_SPEED;\n float band = rd - front;\n float packet = exp(-band * band / (2.0 * RIPPLE_SIGMA * RIPPLE_SIGMA)) * cos(band * RIPPLE_FREQ);\n float reach = 1.0 - smoothstep(RIPPLE_MAX_R * 0.7, RIPPLE_MAX_R, front);\n disp += uPointerRipple * uRippleAmp[i] * packet * reach;\n }\n }\n#endif\n pos += dispAxis * disp;\n#endif\n\n // The scale / rotation / position transform lives on the mesh (modelMatrix), so the\n // orientation matches THREE's Euler-XYZ rather than an in-shader rotation order.\n vec4 world = modelMatrix * vec4(pos, 1.0);\n vWorldPos = world.xyz;\n vViewDir = cameraPosition - world.xyz;\n gl_Position = projectionMatrix * viewMatrix * world;\n vClipPosition = gl_Position;\n}\n`;\n\nexport const fragmentShader = /* glsl */ `\n#define MAX_COLORS ${MAX_COLORS}\n#define MAX_MESH_POINTS ${MAX_MESH_POINTS}\n#define MAX_LIGHTS ${MAX_LIGHTS}\n#define MAX_NOISE_BANDS ${MAX_NOISE_BANDS}\n#define PI 3.14159265359\n\n${simplex2d}\n\n${colorUniforms}\nuniform float uDebug; // dev: 1 = show crease, 2 = show derivative normal\nuniform float uSheen; // white-lift on the flat (low-crease) areas (1 = full)\nuniform float uRoundness; // pose-robust normal-based roundness/thickness strength\nuniform float uIridescence; // thin-film hue shift with view angle (0 = off)\nuniform float uFiberCount;\nuniform float uFiberStrength;\nuniform float uTexture;\nuniform float uCreaseLight;\nuniform float uCreaseSharpness;\nuniform float uCreaseSoftness;\nuniform float uEdgeFade;\nuniform vec2 uResolution;\nuniform float uAmbient;\nuniform int uNumLights;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightIntensity[MAX_LIGHTS];\nuniform int uNumNoiseBands;\nuniform vec4 uNoiseBandBounds[MAX_NOISE_BANDS]; // (startX, endX, startY, endY)\nuniform vec4 uNoiseBandParams[MAX_NOISE_BANDS]; // (feather, strength, frequency, colorAttenuation)\nuniform float uNoiseBandParaPow[MAX_NOISE_BANDS];\n\nvarying vec2 vUv;\nvarying vec3 vWorldPos;\nvarying vec3 vViewDir;\n#ifdef DEPTH_TINT\nuniform float uDepthTint;\nuniform vec3 uDepthTintColor;\nvarying vec4 vClipPosition; // clip-space depth (written by the vertex shader for both programs)\n#endif\n#ifdef EDGE_FEATHER\nuniform float uEdgeFeather; // ribbon long-edge softness (only when it differs from the 0.1 default)\n#endif\n#ifdef POINTER_FX\nuniform float uPointerThin; // 0..1 local translucency near the cursor\nuniform float uPointerHue; // degrees, local hue rotation near the cursor\nuniform float uPointerLighten; // -1..1 local brightness lift near the cursor\nvarying float vPointerFall; // falloff × presence, written by the vertex shader\n#endif\n\n// Cheap value hash for the optional grain overlay (distinct from the simplex hash).\nfloat grainHash(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }\n\nfloat parabola(float x, float k){ return pow(4.0 * x * (1.0 - x), k); }\nfloat mapLinear(float v, float a, float b, float c, float d){ return c + (v - a) * (d - c) / (b - a); }\n\n${colorFns}\n\n// Striations: a subtle high-frequency simplex-noise grain ADDED to the\n// colour — colour-matched (weaker where blue is high), only near folds (crease), and\n// concentrated toward the ends (parabola). Blends in rather than reading as hard lines.\nvec3 surfaceStreaks(vec2 uv, vec3 color, float crease){\n float strength = uFiberStrength; // default 0.2\n float freq = uFiberCount; // default 600\n float colorAtten = 0.9;\n float paraPow = 3.0;\n // Noise bands: inside each rectangular uv region the\n // fiber params are overridden, so the streaks vary per region instead of uniform.\n for (int i = 0; i < MAX_NOISE_BANDS; i++) {\n if (i >= uNumNoiseBands) break;\n vec4 b = uNoiseBandBounds[i];\n vec4 prm = uNoiseBandParams[i];\n float feather = max(prm.x, 1.0e-4);\n float blend =\n smoothstep(b.x - feather, b.x, uv.x) * (1.0 - smoothstep(b.y, b.y + feather, uv.x)) *\n smoothstep(b.z - feather, b.z, uv.y) * (1.0 - smoothstep(b.w, b.w + feather, uv.y));\n strength = mix(strength, prm.y, blend);\n freq = mix(freq, prm.z, blend);\n colorAtten = mix(colorAtten, prm.w, blend);\n paraPow = mix(paraPow, uNoiseBandParaPow[i], blend);\n }\n // The high frequency runs along uv.x (the ribbon's length) so the streaks read as\n // fine lengthwise fibers; end-weighted by 1 - parabola(uv.x).\n float p = 1.0 - parabola(uv.x, paraPow);\n float n0 = simplexNoise(vec2(uv.x * 0.1, uv.y * 0.5));\n float n1 = simplexNoise(vec2(uv.x * (freq + freq * 0.5 * n0), uv.y * 4.0 * n0));\n n1 = mapLinear(n1, -1.0, 1.0, 0.0, 1.0);\n color += n1 * strength * (1.0 - color.b * colorAtten) * crease * p;\n return color;\n}\n\nvoid main(){\n // crease: a foreshortening / fold detector from the screen-space uv derivative.\n // It drives BOTH the roundness shading and where the streaks appear — this is what\n // gives the wave its thickness without any normal-based lighting.\n float crease = dFdy(vUv).y * uResolution.y * uCreaseLight;\n crease = clamp(mapLinear(crease, -1.0, 1.0, 0.0, 1.0), 0.0, 1.0);\n crease = pow(crease, uCreaseSharpness);\n crease = clamp(smoothstep(0.0, uCreaseSoftness, crease), 0.0, 1.0);\n\n // Debug visualisations (dev): 1 = crease value, 2 = derivative surface normal.\n if (uDebug > 0.5) {\n if (uDebug < 1.5) { gl_FragColor = vec4(vec3(crease), 1.0); return; }\n vec3 dn = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n gl_FragColor = vec4(dn * 0.5 + 0.5, 1.0); return;\n }\n\n // Colour: sample the baked 2D palette texture, or fall back to the procedural 1-D\n // gradient (see waveBaseColor).\n vec3 col = waveBaseColor(vUv);\n col = surfaceStreaks(vUv, col, crease);\n col = applyColorGrade(col);\n\n#ifdef POINTER_FX\n // Local hue rotation + brightness lift near the cursor (both fade out with vPointerFall).\n col = hueShift(col, radians(uPointerHue) * vPointerFall);\n col *= 1.0 + uPointerLighten * vPointerFall;\n#endif\n\n // Iridescence: a thin-film / holographic hue that shifts with view angle. Reuses the same\n // camera-facing ratio as roundness (recomputed here, since roundness may be off): grazing parts\n // of the ribbon (low facing) shift hue most, so the colour flows as the ribbon curves. Skipped\n // at 0, so the compiled result is unchanged when off.\n if (uIridescence > 0.001) {\n vec3 iridN = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n float iridFacing = abs(dot(iridN, normalize(vViewDir)));\n col = hueShift(col, (1.0 - iridFacing) * uIridescence * PI);\n }\n\n // Sheen: lift the flat (low-crease) areas toward white. This is\n // pose-dependent (it keys off dFdy(uv.y)), so we keep it gentle and add a robust term.\n col += (1.0 - crease) * 0.25 * uSheen;\n\n // Pose-robust roundness: shade by the camera-facing ratio of the derivative surface\n // normal so the ribbon reads as a rounded, grabbable solid from any angle. Grazing\n // edges darken into shadow (defining the rounded form), the body keeps its full colour,\n // and the most face-on sliver catches a soft highlight. uRoundness = strength.\n if (uRoundness > 0.001) {\n vec3 volN = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n float facing = abs(dot(volN, normalize(vViewDir))); // 1 = facing camera, 0 = edge-on\n col *= mix(1.0 - 0.6 * uRoundness, 1.0, facing); // deepen grazing edges → solid form\n col += smoothstep(0.65, 1.0, facing) * uRoundness * 0.18; // soft highlight on the facing body\n }\n\n // Optional positionable lights (our feature) — additive & gentle, on top of the\n // base shading so the default look is preserved. A finely-subdivided mesh\n // keeps this derivative normal smooth.\n if (uNumLights > 0) {\n vec3 N = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n vec3 Vd = normalize(vViewDir);\n if (dot(N, Vd) < 0.0) N = -N;\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uNumLights) break;\n vec3 L = normalize(uLightPos[i] - vWorldPos);\n vec3 lc = uLightColor[i] * uLightIntensity[i];\n float diff = max(dot(N, L), 0.0);\n float spec = pow(max(dot(N, normalize(L + Vd)), 0.0), 28.0);\n col += col * diff * lc * 0.16 + spec * lc * 0.10;\n }\n }\n col *= 0.55 + clamp(uAmbient, 0.0, 1.0); // overall level; default 0.45 => x1.0 (neutral)\n\n#ifdef DEPTH_TINT\n // Depth tint: fade far fragments toward a colour so a multi-wave stack gains atmospheric\n // separation — near strands keep their colour, far ones recede. Reuses the clip-space depth the\n // wireframe theme fades with (clamp(z*6), where 1 = far).\n col = mix(col, uDepthTintColor, clamp(vClipPosition.z * 6.0, 0.0, 1.0) * uDepthTint);\n#endif\n\n if (uTexture > 0.001) col *= 1.0 + (grainHash(vUv * 850.0) - 0.5) * uTexture * 0.25;\n\n // Soft long edges + optional viewport-edge fade. The edge softness is the hardcoded 0.1 by\n // default (literal branch → byte-identical); EDGE_FEATHER swaps in the uEdgeFeather knob only\n // when it differs, so razor-crisp or vapor-soft edges are both reachable.\n#ifdef EDGE_FEATHER\n float ribEdge =\n smoothstep(0.0, uEdgeFeather, vUv.y) * (1.0 - smoothstep(1.0 - uEdgeFeather, 1.0, vUv.y));\n#else\n float ribEdge = smoothstep(0.0, 0.1, vUv.y) * (1.0 - smoothstep(0.9, 1.0, vUv.y));\n#endif\n float alpha = uOpacity * ribEdge;\n#ifdef POINTER_FX\n alpha *= clamp(1.0 - uPointerThin * vPointerFall, 0.0, 1.0); // solid: local translucency\n#endif\n if (uEdgeFade > 0.001) {\n vec2 sc = gl_FragCoord.xy / max(uResolution, vec2(1.0));\n float vig =\n smoothstep(0.0, uEdgeFade, sc.x) * (1.0 - smoothstep(1.0 - uEdgeFade, 1.0, sc.x)) *\n smoothstep(0.0, uEdgeFade, sc.y) * (1.0 - smoothstep(1.0 - uEdgeFade, 1.0, sc.y));\n alpha *= vig;\n }\n\n // Deep \"squared\" hero colour: formerly done by a framebuffer-squaring blend that REPLACED the\n // destination (punching holes at soft edges / where waves overlap). Squaring here + normal\n // premultiplied compositing (see applyBlendMode) keeps the deep colour and blends correctly.\n col = clamp(col, 0.0, 1.0);\n // Square colour AND alpha so the soft ribbon edges keep the crisp, thin feather of the original\n // squared-blend look — but now composited (premultiplied) rather than replace-blended, so they\n // no longer punch holes. Over an opaque background alpha² still resolves to fully opaque.\n if (uSquared > 0.5) { col *= col; alpha *= alpha; }\n gl_FragColor = vec4(col, alpha);\n#ifdef PREMULTIPLIED_ALPHA\n gl_FragColor.rgb *= gl_FragColor.a;\n#endif\n}\n`;\n\n// ---- Wireframe \"thin-line\" theme ----\n// The same wave geometry, but instead of a solid surface the colour is carved into fine\n// vertical lines (abs(sin(uv.x * lineAmount))) whose thickness scales with the screen-\n// space uv derivative, then mixed line<->background with a depth fade. Used by the dark\n// hero preset. hueShift takes degrees (radians() here) to match the light shader.\nexport const lineFragmentShader = /* glsl */ `\n#define MAX_COLORS ${MAX_COLORS}\n#define MAX_MESH_POINTS ${MAX_MESH_POINTS}\n#define PI 3.14159265359\n\n${simplex2d}\n\n${colorUniforms}\nuniform float uLineAmount; // default 425\nuniform float uLineThickness; // default 1\nuniform float uLineDerivativePower; // default 0.95\nuniform float uMaxWidth; // default 1232\nuniform vec3 uClearColor; // = page background colour (shown between the lines)\n\nvarying vec2 vUv;\nvarying vec4 vClipPosition;\n#ifdef POINTER_FX\nuniform float uPointerThin; // 0..1 — strands taper to hairlines near the cursor\nuniform float uPointerHue; // degrees, local hue rotation near the cursor\nuniform float uPointerLighten; // -1..1 local brightness lift near the cursor\nvarying float vPointerFall; // falloff × presence, written by the vertex shader\n#endif\n\n${colorFns}\n\nvoid main(){\n // Same 2D palette sample + colour ops as the solid theme.\n vec3 color = applyColorGrade(waveBaseColor(vUv));\n\n#ifdef POINTER_FX\n color = hueShift(color, radians(uPointerHue) * vPointerFall);\n color *= 1.0 + uPointerLighten * vPointerFall;\n#endif\n\n // Carve into fine vertical lines; thickness from the screen-space uv derivative.\n vec2 dy = dFdy(vUv);\n float lineThickness = uLineThickness * pow(abs(dy.x * uMaxWidth), uLineDerivativePower);\n#ifdef POINTER_FX\n lineThickness *= clamp(1.0 - uPointerThin * vPointerFall, 0.0, 1.0); // wireframe: taper strands\n#endif\n float a = abs(sin(vUv.x * uLineAmount));\n a = smoothstep(lineThickness, 0.0, a);\n\n // Depth fade: the wave recedes into the background colour with depth. Watch the\n // argument order: clamp(0.0, 1.0, z*6) is a swapped-args trap — it clamps the\n // constant 0.0 into [1.0, z*6], i.e. min(1.0, z*6), which (with our ortho clip.z\n // range) collapses the whole wave to the background. The correct clamp(z*6, 0, 1)\n // gives the proper subtle far-end fade and thin-line look.\n float depthFade = clamp(vClipPosition.z * 6.0, 0.0, 1.0);\n color = mix(uClearColor, color, a * (1.0 - depthFade));\n if (uSquared > 0.5) color *= color; // deep \"squared\" look, now composited not replace-blended\n gl_FragColor = vec4(color, uOpacity);\n#ifdef PREMULTIPLIED_ALPHA\n gl_FragColor.rgb *= gl_FragColor.a;\n#endif\n}\n`;\n\n// ---- Post pass: viewport-edge soft-focus blur + dither grain ----\n\nexport const postVertexShader = /* glsl */ `\nvarying vec2 vUv;\nvoid main(){\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}\n`;\n\nexport const postFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform vec2 uResolution;\nuniform float uBlurAmount;\nuniform int uBlurSamples;\nuniform float uGrainAmount;\nuniform float uTime;\nvarying vec2 vUv;\n\nfloat random2(vec2 st){ return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453); }\n\n// Angular (spin) blur: rotate the sample coord around the centre and\n// accumulate — a tangential smear that grows toward the edges. Carries alpha so a\n// transparent background survives the post pass.\nvec4 blurAngular(sampler2D tex, vec2 uv, float angle, int samples){\n vec4 total = vec4(0.0);\n vec2 coord = uv - 0.5;\n float dist = 1.0 / float(samples);\n vec2 dir = vec2(cos(angle * dist), sin(angle * dist));\n mat2 rot = mat2(dir.x, dir.y, -dir.y, dir.x);\n for (int i = 0; i < 64; i++){\n if (i >= samples) break;\n total += texture2D(tex, coord + 0.5);\n coord = coord * rot; // row-vector order (coord * rot) sets the spin direction\n }\n return total * dist;\n}\n\nvoid main(){\n vec4 sceneColor = texture2D(tDiffuse, vUv);\n vec4 blurColor = blurAngular(tDiffuse, vUv, uBlurAmount, uBlurSamples);\n // blurPower: keep a sharp band weighted to the middle, blurring toward top & bottom.\n float blurPower = smoothstep(0.0, 0.7, vUv.y) - smoothstep(0.2, 1.0, vUv.y);\n vec4 color = mix(blurColor, sceneColor, blurPower);\n // Static film grain: keyed off gl_FragCoord only (no uTime), so it doesn't flicker.\n color.rgb += mix(uGrainAmount, -uGrainAmount, random2(gl_FragCoord.xy * 0.01)) * (4.0 / 255.0);\n gl_FragColor = color; // preserve alpha → transparent background works\n}\n`;\n\n// ---- Post pass: ordered (Bayer) dithering ----\n//\n// DERIVED FROM @paper-design/shaders `image-dithering` (https://github.com/paper-design/shaders,\n// Apache-2.0 — see THIRD-PARTY-NOTICES.md). The Bayer matrices, getBayerValue, and the brightness /\n// luminance-quantization / hue-preserving \"original colours\" recolour are paper's. Adapted to a\n// post pass: samples the composited scene (tDiffuse) at full-frame vUv instead of paper's sized/fit\n// u_image UV, drops the frame/aspect machinery, fixes the 8x8 matrix (paper's default), and gates\n// via uDitherStrength. The int[] arrays + dynamic indexing compile because three builds\n// ShaderMaterials as \"#version 300 es\". Runs AFTER OutputPass, so it dithers display-space colour;\n// keyed off gl_FragCoord/tDiffuse only (no uTime) → deterministic, friendly to pixel-digest checks.\nexport const ditherFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform vec2 uResolution;\nuniform float uDitherStrength; // 0..1 mix back toward the original\nuniform float uDitherScale; // pixel-block size in device px (paper: u_pxSize)\nuniform float uDitherSteps; // quantization levels (paper: u_colorSteps)\nvarying vec2 vUv;\n\nconst int bayer2x2[4] = int[4](0, 2, 3, 1);\nconst int bayer4x4[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);\nconst int bayer8x8[64] = int[64](\n 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26,\n 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,\n 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25,\n 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21\n);\nfloat getBayerValue(vec2 uv, int size){\n ivec2 pos = ivec2(fract(uv / float(size)) * float(size));\n int index = pos.y * size + pos.x;\n if (size == 2) return float(bayer2x2[index]) / 4.0;\n else if (size == 4) return float(bayer4x4[index]) / 16.0;\n else if (size == 8) return float(bayer8x8[index]) / 64.0;\n return 0.0;\n}\n\nvoid main(){\n float pxSize = max(uDitherScale, 1.0);\n vec2 pxSizeUV = gl_FragCoord.xy / pxSize;\n vec2 sampleUV = (floor(gl_FragCoord.xy / pxSize) + 0.5) * pxSize / max(uResolution, vec2(1.0));\n vec4 image = texture2D(tDiffuse, sampleUV);\n\n float lum = dot(vec3(0.2126, 0.7152, 0.0722), image.rgb);\n float colorSteps = max(floor(uDitherSteps), 1.0);\n\n float dithering = getBayerValue(pxSizeUV, 8) - 0.5; // paper's default 8x8 ordered screen\n float brightness = clamp(lum + dithering / colorSteps, 0.0, 1.0);\n brightness = mix(0.0, brightness, image.a);\n float quantLum = floor(brightness * colorSteps + 0.5) / colorSteps;\n\n // paper's \"original colours\" path: keep the source hue, quantize luminance.\n vec3 color = image.rgb / max(lum, 0.001) * quantLum;\n float quantAlpha = floor(image.a * colorSteps + 0.5) / colorSteps;\n float opacity = mix(quantLum, 1.0, quantAlpha);\n\n gl_FragColor = mix(image, vec4(color, opacity), clamp(uDitherStrength, 0.0, 1.0));\n}\n`;\n\n// ---- Post pass: innerLight (volumetric light streaks) — another \"layered\" post shader ----\n//\n// Radial light-scattering (à la GPU Gems 3): from each pixel, march toward a light point and\n// accumulate the wave's own brightness (weighted by alpha, so only opaque pixels emit), then add\n// the streaks back. Runs in the scene zone so it scatters the raw, pre-tone-map wave like bloom.\nexport const innerLightFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uInnerLight; // 0..1 strength of the added light\nuniform float uInnerLightDensity; // ray length / spread\nuniform float uInnerLightDecay; // per-sample falloff (<1)\nuniform vec2 uInnerLightCenter; // light source, UV (0..1)\nvarying vec2 vUv;\n\nconst int LIGHT_SAMPLES = 24;\n\nfloat luma(vec3 c){ return dot(c, vec3(0.2126, 0.7152, 0.0722)); }\n\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n vec2 delta = (vUv - uInnerLightCenter) * (uInnerLightDensity / float(LIGHT_SAMPLES));\n vec2 coord = vUv;\n float decay = 1.0;\n vec3 rays = vec3(0.0);\n for (int i = 0; i < LIGHT_SAMPLES; i++){\n coord -= delta;\n vec4 s = texture2D(tDiffuse, coord);\n rays += s.rgb * s.a * decay; // only opaque (wave) pixels emit light\n decay *= uInnerLightDecay;\n }\n rays /= float(LIGHT_SAMPLES);\n vec3 outc = src.rgb + rays * uInnerLight;\n float outA = max(src.a, luma(rays) * uInnerLight); // shafts stay visible over the transparent bg\n gl_FragColor = vec4(outc, clamp(outA, 0.0, 1.0));\n}\n`;\n\n// ---- Post pass: halftone (rotated dot screen) ----\n//\n// DERIVED FROM @paper-design/shaders `halftone-dots` (https://github.com/paper-design/shaders,\n// Apache-2.0 — see THIRD-PARTY-NOTICES.md). Ports the \"classic\" dot type + \"original colours\" path:\n// paper's getCircle (dot radius ← 1 − luminance, fwidth-antialiased) and sigmoid-contrast luminance,\n// sampled once per cell centre. Adapted to a post pass — samples the composited scene (tDiffuse)\n// instead of paper's sized u_image, drops the gooey/holes/soft dot types, the diagonal grid and the\n// grain layers, and composites transparent between dots. Contrast/radius fixed at paper's defaults.\nexport const halftoneFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform vec2 uResolution;\nuniform float uHalftone; // 0..1 mix\nuniform float uHalftoneCell; // dot cell size in device px (paper: u_size)\nuniform float uHalftoneAngle; // screen rotation (radians, paper: u_rotation)\nvarying vec2 vUv;\n\nfloat sigmoid(float x, float k){ return 1.0 / (1.0 + exp(-k * (x - 0.5))); }\n// paper's classic dot: radius grows as the sampled cell darkens (1 - lum), soft edge via fwidth.\nfloat getCircle(vec2 uv, float lum, float baseR){\n float r = mix(0.25 * baseR, 0.0, lum);\n float d = length(uv - 0.5);\n float aa = fwidth(d);\n return 1.0 - smoothstep(r - aa, r + aa, d);\n}\n\nvoid main(){\n float ca = cos(uHalftoneAngle);\n float sa = sin(uHalftoneAngle);\n mat2 rot = mat2(ca, sa, -sa, ca);\n float cell = max(uHalftoneCell, 2.0);\n vec2 gridPx = rot * gl_FragCoord.xy; // rotate the screen into the dot grid\n vec2 cellId = floor(gridPx / cell);\n vec2 inCell = fract(gridPx / cell); // position within the cell (0..1)\n vec2 centrePx = transpose(rot) * ((cellId + 0.5) * cell); // cell centre, back in screen px\n vec4 tex = texture2D(tDiffuse, centrePx / max(uResolution, vec2(1.0)));\n\n float k = 2.0; // sigmoid contrast (paper default)\n vec3 c = vec3(sigmoid(tex.r, k), sigmoid(tex.g, k), sigmoid(tex.b, k));\n float lum = dot(vec3(0.2126, 0.7152, 0.0722), c);\n lum = mix(1.0, lum, tex.a);\n float dot = getCircle(inCell, lum, 1.3); // baseR 1.3 ≈ paper original-colours default\n vec4 dots = vec4(tex.rgb, tex.a * dot); // wave-coloured dots, transparent between\n gl_FragColor = mix(texture2D(tDiffuse, vUv), dots, clamp(uHalftone, 0.0, 1.0));\n}\n`;\n\n// ---- Post pass: heatmap (map luminance → thermal palette) — a finish-zone filter ----\nexport const heatmapFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uHeatmap; // 0..1 mix\nvarying vec2 vUv;\nvec3 heat(float t){\n t = clamp(t, 0.0, 1.0);\n vec3 c = mix(vec3(0.0, 0.0, 0.4), vec3(0.0, 0.6, 1.0), smoothstep(0.0, 0.25, t));\n c = mix(c, vec3(0.0, 1.0, 0.4), smoothstep(0.25, 0.5, t));\n c = mix(c, vec3(1.0, 1.0, 0.0), smoothstep(0.5, 0.75, t));\n c = mix(c, vec3(1.0, 0.1, 0.0), smoothstep(0.75, 1.0, t));\n return c;\n}\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n float l = dot(src.rgb, vec3(0.299, 0.587, 0.114));\n gl_FragColor = vec4(mix(src.rgb, heat(l), clamp(uHeatmap, 0.0, 1.0)), src.a);\n}\n`;\n\n// ---- Post pass: paper texture (fibrous substrate shading) — a finish-zone overlay ----\nexport const paperTextureFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uPaper; // 0..1 strength\nuniform float uPaperScale; // grain scale\nvarying vec2 vUv;\nfloat h21(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n vec2 p = gl_FragCoord.xy / max(uPaperScale, 0.5);\n float fiber = h21(floor(p)) * 0.5 + h21(floor(p * vec2(0.3, 3.0))) * 0.5; // directional fibers\n float tex = mix(fiber, h21(gl_FragCoord.xy), 0.3); // + fine speckle\n float shade = 1.0 - (tex - 0.5) * 0.35;\n gl_FragColor = vec4(src.rgb * mix(1.0, shade, clamp(uPaper, 0.0, 1.0)), src.a);\n}\n`;\n\n// ---- Post pass: CMYK halftone (four rotated dot screens) — a finish-zone filter ----\nexport const halftoneCmykFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uHalftoneCmyk; // 0..1 mix\nuniform float uHalftoneCmykCell; // dot cell size in device px\nvarying vec2 vUv;\n// One rotated halftone dot screen for a channel value.\nfloat dotScreen(vec2 coord, float value, float angle, float cell){\n float ca = cos(angle);\n float sa = sin(angle);\n vec2 r = mat2(ca, sa, -sa, ca) * coord;\n vec2 c = fract(r / max(cell, 2.0)) - 0.5;\n float radius = sqrt(clamp(value, 0.0, 1.0)) * 0.5;\n return smoothstep(radius, radius - 0.06, length(c));\n}\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n float k = 1.0 - max(max(src.r, src.g), src.b); // RGB → CMYK\n float invK = max(1.0 - k, 1e-3);\n float cyan = (1.0 - src.r - k) / invK;\n float mag = (1.0 - src.g - k) / invK;\n float yel = (1.0 - src.b - k) / invK;\n vec2 coord = gl_FragCoord.xy;\n float cell = uHalftoneCmykCell;\n float dc = dotScreen(coord, cyan, 1.309, cell); // 75°\n float dm = dotScreen(coord, mag, 0.262, cell); // 15°\n float dy = dotScreen(coord, yel, 0.0, cell); // 0°\n float dk = dotScreen(coord, k, 0.785, cell); // 45°\n // Subtractive: cyan ink absorbs red, magenta absorbs green, yellow absorbs blue, black absorbs all.\n vec3 outc = vec3(1.0) - vec3(dc, 0.0, 0.0) - vec3(0.0, dm, 0.0) - vec3(0.0, 0.0, dy) - vec3(dk);\n outc = clamp(outc, 0.0, 1.0);\n gl_FragColor = vec4(mix(src.rgb, outc, clamp(uHalftoneCmyk, 0.0, 1.0)), src.a);\n}\n`;\n"],"mappings":";;;;;;;;;;;;;;;AAqBA,MAAM,YAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoC7B,MAAM,gBAA2B;;;;;;;;;;;;;;;;;;;;;;;;AA2BjC,MAAM,WAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyF5B,MAAa,eAA0B;EACrC,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6MZ,MAAa,iBAA4B;;;;;;;EAOvC,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+Cd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4JX,MAAa,qBAAgC;;;;;EAK3C,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;EAgBd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCX,MAAa,mBAA8B;;;;;;;AAQ3C,MAAa,qBAAgC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkD7C,MAAa,uBAAkC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqD/C,MAAa,2BAAsC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCnD,MAAa,yBAAoC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCjD,MAAa,wBAAmC;;;;;;;;;;;;;;;;;;AAoBhD,MAAa,6BAAwC;;;;;;;;;;;;;;;AAiBrD,MAAa,6BAAwC"}
1
+ {"version":3,"file":"shaders.js","names":[],"sources":["../../src/renderer/shaders.ts"],"sourcesContent":["import { MAX_COLORS, MAX_LIGHTS, MAX_MESH_POINTS, MAX_NOISE_BANDS } from \"../config/model\";\nimport { RIBBON_Z_CENTER } from \"./WaveGeometry\";\n\n/**\n * The wave shaders. Vertex: a flat plane is Y-displaced by simplex noise, then\n * twisted by three axis-rotations `freq * expStep(uv, power)` where\n * `expStep(x,n) = exp2(-exp2(n)*pow(x,n))` is a falloff (rotation concentrated at\n * the uv=0 edge), with diagonal axes + an animated X wobble. Fragment: uses NO\n * normal-based lighting — \"thickness\" comes from `crease`, a foreshorten/fold\n * detector built from `dFdy(uv)`, used to lift flat areas toward white\n * (`col += (1-crease)*0.25`) and to localise the striations. Striations are subtle\n * high-frequency simplex noise ADDED to the colour, colour-matched via (1-blue)\n * and end-weighted via a parabola — so they blend rather than form hard lines.\n * Our additions: gradient stops/types for colour, and an optional additive light\n * layer (kept gentle so the default look is preserved).\n */\n\n// Noise function: xxHash-seeded unit-vector gradients + a Gustavson simplex. It uses\n// GLSL ES 3.00 integer ops (floatBitsToUint, unsigned bit-shifts) — available with no\n// glslVersion change because three compiles non-raw ShaderMaterials as \"#version 300 es\"\n// already. `hash` returns a vec2 here — the cheap grain hash in the fragment is named\n// `grainHash` to avoid clashing with it.\nconst simplex2d = /* glsl */ `\nfloat xxhash(vec2 x){\n uvec2 t = floatBitsToUint(x);\n uint h = 0xc2b2ae3du * t.x + 0x165667b9u;\n h = (h << 17u | h >> 15u) * 0x27d4eb2fu;\n h += 0xc2b2ae3du * t.y;\n h = (h << 17u | h >> 15u) * 0x27d4eb2fu;\n h ^= h >> 15u;\n h *= 0x85ebca77u;\n h ^= h >> 13u;\n h *= 0xc2b2ae3du;\n h ^= h >> 16u;\n return uintBitsToFloat(h >> 9u | 0x3f800000u) - 1.0;\n}\nvec2 hash(vec2 x){\n float k = 6.283185307 * xxhash(x);\n return vec2(cos(k), sin(k));\n}\nfloat simplexNoise(in vec2 p){\n const float K1 = 0.366025404; // (sqrt(3)-1)/2\n const float K2 = 0.211324865; // (3-sqrt(3))/6\n vec2 i = floor(p + (p.x + p.y) * K1);\n vec2 a = p - i + (i.x + i.y) * K2;\n float m = step(a.y, a.x);\n vec2 o = vec2(m, 1.0 - m);\n vec2 b = a - o + K2;\n vec2 c = a - 1.0 + 2.0 * K2;\n vec3 h = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);\n vec3 n = h * h * h * vec3(dot(a, hash(i + 0.0)), dot(b, hash(i + o)), dot(c, hash(i + 1.0)));\n return dot(n, vec3(32.99)); // analytic factor (= 2916*sqrt(2)/125)\n}\n`;\n\n// Uniforms shared by BOTH fragment shaders (solid + wireframe line): the palette/gradient\n// inputs and the colour-grade knobs. Each shader declares its theme-specific uniforms beside\n// this block. Requires MAX_COLORS / MAX_MESH_POINTS #defines.\nconst colorUniforms = /* glsl */ `\nuniform vec3 uColors[MAX_COLORS];\nuniform float uColorPos[MAX_COLORS];\nuniform int uColorCount;\nuniform int uGradType;\nuniform float uGradAngle;\nuniform float uGradShift;\nuniform vec2 uMeshPointPos[MAX_MESH_POINTS];\nuniform vec3 uMeshPointColor[MAX_MESH_POINTS];\nuniform float uMeshPointInfluence[MAX_MESH_POINTS];\nuniform int uMeshPointCount;\nuniform float uMeshSoftness;\nuniform sampler2D uPalette; // baked 2D palette texture\nuniform float uUsePalette; // >0.5 = sample the texture; else procedural grad()\nuniform float uPaletteRaw; // >0.5 = sample palette by raw (uv.x,uv.y), not gradCoord\nuniform vec2 uPaletteScale;\nuniform vec2 uPaletteOffset;\nuniform float uPaletteRotation;\nuniform float uHueShift;\nuniform float uContrast;\nuniform float uSaturation;\nuniform float uOpacity;\nuniform float uSquared; // 1 = square the output colour (the deep \"squared\" hero look)\n`;\n\n// Colour helpers + the palette/gradient sampler shared by both fragment shaders.\n// Interpolate AFTER ${\"simplex2d\"} and ${\"colorUniforms\"} (gradCoord needs both) and a PI define.\nconst colorFns = /* glsl */ `\nvec3 contrastFn(vec3 v, float a){ return (v - 0.5) * a + 0.5; }\nvec3 desaturate(vec3 color, float factor){\n vec3 gray = vec3(dot(vec3(0.299, 0.587, 0.114), color));\n return mix(color, gray, factor);\n}\nvec3 hueShift(vec3 color, float shift){\n vec3 g = vec3(0.57735);\n vec3 proj = g * dot(g, color);\n vec3 U = color - proj;\n vec3 W = cross(g, U);\n return U * cos(shift) + W * sin(shift) + proj;\n}\n\n// Our gradient: interpolate stops by their positions (uColorPos sorted ascending).\nvec3 grad(float u){\n u = clamp(u, 0.0, 1.0);\n vec3 col = uColors[0];\n for (int i = 0; i < MAX_COLORS - 1; i++){\n if (i >= uColorCount - 1) break;\n float p0 = uColorPos[i];\n float p1 = uColorPos[i + 1];\n if (u >= p0){\n float t = clamp((u - p0) / max(p1 - p0, 1e-5), 0.0, 1.0);\n col = mix(uColors[i], uColors[i + 1], t);\n }\n }\n return col;\n}\n\n// iOS-style 2D colour field. Each control point contributes an inverse-distance\n// weight; normalising the sum fills the whole surface without dark seams.\nvec3 meshGradient(vec2 uv){\n vec3 colorSum = vec3(0.0);\n float weightSum = 0.0;\n float exponent = mix(4.8, 1.35, clamp(uMeshSoftness, 0.0, 1.0));\n for (int i = 0; i < MAX_MESH_POINTS; i++){\n if (i >= uMeshPointCount) break;\n float influence = max(uMeshPointInfluence[i], 0.05);\n float distanceFromPoint = length(uv - uMeshPointPos[i]) / influence;\n float weight = 1.0 / (pow(max(distanceFromPoint, 0.012), exponent) + 0.002);\n colorSum += uMeshPointColor[i] * weight;\n weightSum += weight;\n }\n return colorSum / max(weightSum, 0.0001);\n}\n\n// Map a surface uv to the 0–1 gradient coordinate per gradient type. uGradShift\n// adds a low-frequency simplex warp so the colour varies in 2D (along the length\n// as well as across the width) — a 2D palette feel instead\n// of flat 1-D bands.\nfloat gradCoord(vec2 uv){\n float warp = uGradShift * simplexNoise(uv * 1.6 + 4.0);\n if (uGradType == 1){ return clamp(length(uv - 0.5) * 2.0 + warp, 0.0, 1.0); } // radial\n if (uGradType == 2){ return fract(atan(uv.y - 0.5, uv.x - 0.5) / (2.0 * PI) + 0.5 + warp); } // conic\n vec2 dir = vec2(sin(uGradAngle), cos(uGradAngle)); // linear, angled\n return clamp(dot(uv - 0.5, dir) + 0.5 + warp, 0.0, 1.0);\n}\n\n// One base-colour sample for the whole surface: rotate/scale/offset the raw-palette uv,\n// then pick the mesh field / baked 2D texture / procedural stops by mode. The raw palette\n// is sampled by (uv.x, uv.y) directly; the stops-generated texture is sampled via\n// gradCoord so its angle/type/warp still apply.\nvec3 waveBaseColor(vec2 uv){\n float gc = gradCoord(uv);\n vec2 mediaUv = uv - 0.5;\n float mediaCos = cos(uPaletteRotation);\n float mediaSin = sin(uPaletteRotation);\n mediaUv = vec2(\n mediaCos * mediaUv.x + mediaSin * mediaUv.y,\n -mediaSin * mediaUv.x + mediaCos * mediaUv.y\n );\n mediaUv = mediaUv * uPaletteScale + 0.5 + uPaletteOffset;\n vec2 puv = uPaletteRaw > 0.5\n ? clamp(mediaUv, 0.0, 1.0)\n : vec2(gc, clamp(uv.y, 0.0, 1.0));\n return uGradType == 3\n ? meshGradient(uv)\n : (uUsePalette > 0.5 ? texture2D(uPalette, puv).rgb : grad(gc));\n}\n\n// The shared colour grade: contrast → desaturate → hue rotate (degrees).\nvec3 applyColorGrade(vec3 c){\n c = contrastFn(c, uContrast);\n c = desaturate(c, 1.0 - uSaturation);\n return hueShift(c, radians(uHueShift));\n}\n`;\n\nexport const vertexShader = /* glsl */ `\n${simplex2d}\n\nuniform float uTime, uSpeed, uSeed;\nuniform float uDispFreqX, uDispFreqZ, uDispAmount;\nuniform float uDetailFreq, uDetailAmount; // 2nd displacement octave (only read under DETAIL_OCTAVE)\nuniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;\nuniform float uLoopSeconds; // seamless-loop period (only read under LOOP_MOTION)\n\n// Helix (optional). Behind HELIX so a wave without one compiles the exact same program — same\n// byte-identity contract as the pointer block below.\n#ifdef HELIX\nuniform float uHelixTurns; // full turns from one end of the ribbon to the other\nuniform float uHelixRadius; // orbit radius: carries the whole ribbon around the axis\nuniform float uHelixRoll; // cross-section roll, as a fraction of the turns (1 = rigid ladder)\nuniform float uHelixPhase; // degrees\n#endif\n\nvarying vec2 vUv;\nvarying vec3 vWorldPos;\nvarying vec3 vViewDir;\nvarying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade\n\n// Pointer field (optional, additive). ALL declarations here sit behind POINTER_FX so a wave with\n// no interaction config compiles the exact same program (JS-side uniform entries are always present\n// — see makeUniforms — but three only uploads uniforms the compiled program actually declares).\n#ifdef POINTER_FX\nuniform vec2 uPointer; // smoothed pointer, NDC (-1..1)\nuniform float uPointerActive; // presence ramp 0..1 × per-wave influence\nuniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)\nuniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)\nuniform float uPointerAgitate;\nuniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)\nuniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)\nuniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)\n// Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the\n// ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).\nuniform float uShapeFlow;\nvarying float vPointerFall; // falloff × presence — consumed by both fragment themes\n#ifdef POINTER_RIPPLES\nuniform vec2 uRippleOrigin[4]; // NDC\nuniform float uRippleAge[4]; // seconds since spawn (CPU-computed)\nuniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)\nuniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)\nconst float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward\nconst float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)\nconst float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)\nconst float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame\n#endif\n#endif\n\n// expStep: a falloff from 1 (at x=0) toward 0, sharpness set by n. The\n// max() guards pow(0, n) (= Infinity → NaN) so negative n is safe — negative n\n// just concentrates the twist toward the OTHER end instead.\nfloat expStep(float x, float n){ return exp2(-exp2(n) * pow(max(x, 1.0e-3), n)); }\n\n// rotationMatrix (mat4), used row-vector style: pos = (vec4(pos,1) * R).xyz\nmat4 rotationMatrix(vec3 axis, float angle){\n axis = normalize(axis);\n float s = sin(angle), c = cos(angle), oc = 1.0 - c;\n return mat4(\n oc*axis.x*axis.x + c, oc*axis.x*axis.y - axis.z*s, oc*axis.z*axis.x + axis.y*s, 0.0,\n oc*axis.x*axis.y + axis.z*s, oc*axis.y*axis.y + c, oc*axis.y*axis.z - axis.x*s, 0.0,\n oc*axis.z*axis.x - axis.y*s, oc*axis.y*axis.z + axis.x*s, oc*axis.z*axis.z + c, 0.0,\n 0.0, 0.0, 0.0, 1.0\n );\n}\n\nvoid main(){\n vUv = uv;\n#ifndef LOOP_MOTION\n float t = uTime * uSpeed + uSeed;\n#endif\n\n#ifdef LOOP_MOTION\n // Seamless loop: rather than scrolling the noise field linearly by t (which never repeats),\n // sample it on a circle of radius loopR at angle loopTheta — exactly periodic with period\n // uLoopSeconds. The tangential speed loopR·dθ/dt equals uSpeed, so the looped motion advances\n // at the same rate as the linear drift, just curved into a closed orbit (it orbits rather than\n // drifts — the trade-off for a seamless loop, hence opt-in). uSeed offsets the phase so stacked\n // waves keep their relative motion while sharing the single period.\n float loopTheta = uTime * (6.28318530718 / uLoopSeconds) + uSeed;\n float loopR = uSpeed * uLoopSeconds * 0.159154943092; // = uSpeed·uLoopSeconds / (2π)\n vec2 loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));\n#endif\n\n // The base geometry is already a baked hairpin fold. On top of it we deform the\n // vertices: a displacement lifts Y by simplex noise of the (x,z) position, then\n // three axis-rotations twist the strip.\n vec3 pos = position;\n#ifdef LOOP_MOTION\n pos.y += uDispAmount * simplexNoise(vec2(pos.x * uDispFreqX, pos.z * uDispFreqZ) + loopOff);\n#else\n pos.y += uDispAmount * simplexNoise(vec2(pos.x * uDispFreqX + t, pos.z * uDispFreqZ + t));\n#endif\n#ifdef DETAIL_OCTAVE\n // A second, finer octave riding on the broad swell — fine ripples on top of the big shape, a\n // shape vocabulary single-octave displacement can't reach. Shares the loop orbit so it stays\n // periodic when looping.\n#ifdef LOOP_MOTION\n pos.y += uDetailAmount * simplexNoise(vec2(pos.x * uDetailFreq, pos.z * uDetailFreq) + loopOff);\n#else\n pos.y += uDetailAmount * simplexNoise(vec2(pos.x * uDetailFreq + t, pos.z * uDetailFreq + t));\n#endif\n#endif\n\n#ifdef HELIX\n // Helix — the one shape the three twists below cannot reach. Their angle is freq * expStep(uv),\n // a MONOTONE falloff, so it can only ramp once; this one is periodic in uv.y (the length), so\n // uHelixTurns full turns land evenly from end to end. Runs AFTER the displacement so the noise\n // above still samples the undeformed pos.x/pos.z (byte-identical sampling), and BEFORE the twist\n // so the two compose.\n // roll rolls the ribbon's own cross-section about the axis in step with the sweep, swinging\n // its two long edges onto opposite sides — one wave becomes a ladder whose edges are\n // both strands (pair with the wireframe theme's rungs for the rungs between them).\n // radius carries the whole ribbon around the axis instead, orientation intact — a narrow ribbon\n // then reads as ONE strand, and a second wave at phase+180 is the other.\n float hAng = 6.28318530718 * uHelixTurns * uv.y + radians(uHelixPhase);\n // Roll about the ribbon's width centre, not the origin — see RIBBON_Z_CENTER in WaveGeometry.\n float rollA = hAng * uHelixRoll;\n float rollC = cos(rollA), rollS = sin(rollA);\n vec2 rel = vec2(pos.y, pos.z - ${RIBBON_Z_CENTER.toFixed(1)});\n pos.y = rel.x * rollC - rel.y * rollS;\n pos.z = ${RIBBON_Z_CENTER.toFixed(1)} + rel.x * rollS + rel.y * rollC;\n pos.y += uHelixRadius * cos(hAng);\n pos.z += uHelixRadius * sin(hAng);\n#endif\n\n // The X-twist frequency feeding rotB. Two modes: by default uTwFreqX is used\n // directly; the variant (used by the Wave 4 preset) modulates it with\n // simplex noise indexed along the ribbon (uv.y) so the twist breathes over time.\n // We gate the wobble with a #define so the compiled program is unchanged when off.\n float twistXFreq = uTwFreqX;\n#ifdef TWIST_MOTION\n#ifdef LOOP_MOTION\n float twistXNoise = simplexNoise(vec2(vUv.y * 2.0, 0.0) + loopOff);\n#else\n float twistXNoise = simplexNoise(vec2(vUv.y * 2.0, t));\n#endif\n twistXFreq = uTwFreqX - twistXNoise * 0.1;\n#endif\n\n // Three-axis twist: expStep falloff sets how\n // sharply each rotation concentrates toward an edge. rotA keys off uv.x, rotB/rotC\n // off uv.y; axes (0.5,0,0.5) and (0,0.5,0.5) are normalised inside rotationMatrix.\n mat4 rotA = rotationMatrix(vec3(0.5, 0.0, 0.5), uTwFreqY * expStep(uv.x, uTwPowY));\n mat4 rotB = rotationMatrix(vec3(0.0, 0.5, 0.5), twistXFreq * expStep(uv.y, uTwPowX));\n mat4 rotC = rotationMatrix(vec3(0.5, 0.0, 0.5), uTwFreqZ * expStep(uv.y, uTwPowZ));\n pos = (vec4(pos, 1.0) * rotA).xyz;\n pos = (vec4(pos, 1.0) * rotB).xyz;\n pos = (vec4(pos, 1.0) * rotC).xyz;\n\n#ifdef POINTER_FX\n // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space\n // falloff around the smoothed cursor — a circle at uShapeFlow 0, stretched along the ribbon as it\n // rises. Everything here is ADDITIVE and fenced, so the shared path above/below is untouched and\n // byte-identical when POINTER_FX is off.\n // Shared clip-space transform, computed once and reused for the cursor metric and the ribbon\n // tangent (the compiler is not guaranteed to CSE the triple product otherwise). Associativity is\n // unchanged, so preClip is bit-for-bit what the plain P*V*M*v product produced.\n mat4 mvp = projectionMatrix * viewMatrix * modelMatrix;\n vec4 preClip = mvp * vec4(pos, 1.0);\n // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.\n vec2 ndcHere = preClip.xy / max(preClip.w, 1.0e-6);\n vec2 dp = (ndcHere - uPointer) * vec2(uPointerAspect, 1.0);\n // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the\n // ribbon and stays tight across it — the \"flows with the material\" feel, per-vertex (so it follows\n // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)\n // carried through the SAME twist as the surface. The camera is orthographic (affine, w=1), so the\n // axis's screen image is the linear map of the DIRECTION (w=0): one mat·dir, no second\n // point-projection and no perspective divide. (A true per-pixel uv would need GPU picking — the\n // visible surface is shader-displaced, so a CPU raycast of the base geometry misses.)\n if (uShapeFlow > 0.0) {\n vec3 tangentLocal = (((vec4(1.0, 0.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;\n vec2 tang = (mvp * vec4(tangentLocal, 0.0)).xy * vec2(uPointerAspect, 1.0);\n float tl = length(tang);\n if (tl > 1.0e-6) {\n tang /= tl;\n vec2 nrm = vec2(-tang.y, tang.x);\n dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length\n }\n }\n float fall = smoothstep(uPointerRadius, 0.0, length(dp));\n vPointerFall = fall * uPointerActive;\n // Displacement axis = local +Y carried through the SAME three twist rotations as pos (row-vector\n // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.\n vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;\n // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which\n // would force restructuring the shared path). Loop-safe under both time variants.\n#ifdef LOOP_MOTION\n float disp = uPointerAgitate * vPointerFall\n * simplexNoise(vec2(pos.x * uDispFreqX * 3.0, pos.z * uDispFreqZ * 3.0) + loopOff * 4.0);\n#else\n float disp = uPointerAgitate * vPointerFall\n * simplexNoise(vec2(pos.x * uDispFreqX * 3.0 + t * 4.0, pos.z * uDispFreqZ * 3.0));\n#endif\n // Membrane push/pull: a smooth dome (vPointerFall is the falloff) that swells toward you (+ repel)\n // or dents away (− attract) at the cursor, riding along with the sprung field.\n disp += uPointerPush * vPointerFall;\n // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points\n // from cursor to vertex; \"behind\" is how far the vertex sits opposite the velocity (0 ahead → 1 a\n // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.\n vec2 velC = uPointerVel * vec2(uPointerAspect, 1.0);\n float wakeSpeed = length(velC);\n if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {\n float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);\n disp -= uPointerWake * vPointerFall * behind * smoothstep(0.05, 0.6, wakeSpeed);\n }\n#ifdef POINTER_RIPPLES\n for (int i = 0; i < 4; i++) {\n if (uRippleAmp[i] > 0.0) {\n float rd = length((preClip.xy / max(preClip.w, 1.0e-6) - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));\n // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on\n // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),\n // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp\n // envelope fades the whole packet over its lifetime; reach fades it as the crest leaves frame.\n float front = uRippleAge[i] * RIPPLE_WAVE_SPEED;\n float band = rd - front;\n float packet = exp(-band * band / (2.0 * RIPPLE_SIGMA * RIPPLE_SIGMA)) * cos(band * RIPPLE_FREQ);\n float reach = 1.0 - smoothstep(RIPPLE_MAX_R * 0.7, RIPPLE_MAX_R, front);\n disp += uPointerRipple * uRippleAmp[i] * packet * reach;\n }\n }\n#endif\n pos += dispAxis * disp;\n#endif\n\n // The scale / rotation / position transform lives on the mesh (modelMatrix), so the\n // orientation matches THREE's Euler-XYZ rather than an in-shader rotation order.\n vec4 world = modelMatrix * vec4(pos, 1.0);\n vWorldPos = world.xyz;\n vViewDir = cameraPosition - world.xyz;\n gl_Position = projectionMatrix * viewMatrix * world;\n vClipPosition = gl_Position;\n}\n`;\n\nexport const fragmentShader = /* glsl */ `\n#define MAX_COLORS ${MAX_COLORS}\n#define MAX_MESH_POINTS ${MAX_MESH_POINTS}\n#define MAX_LIGHTS ${MAX_LIGHTS}\n#define MAX_NOISE_BANDS ${MAX_NOISE_BANDS}\n#define PI 3.14159265359\n\n${simplex2d}\n\n${colorUniforms}\nuniform float uDebug; // dev: 1 = show crease, 2 = show derivative normal\nuniform float uSheen; // white-lift on the flat (low-crease) areas (1 = full)\nuniform float uRoundness; // pose-robust normal-based roundness/thickness strength\nuniform float uIridescence; // thin-film hue shift with view angle (0 = off)\nuniform float uFiberCount;\nuniform float uFiberStrength;\nuniform float uTexture;\nuniform float uCreaseLight;\nuniform float uCreaseSharpness;\nuniform float uCreaseSoftness;\nuniform float uEdgeFade;\nuniform vec2 uResolution;\nuniform float uAmbient;\nuniform int uNumLights;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightIntensity[MAX_LIGHTS];\nuniform int uNumNoiseBands;\nuniform vec4 uNoiseBandBounds[MAX_NOISE_BANDS]; // (startX, endX, startY, endY)\nuniform vec4 uNoiseBandParams[MAX_NOISE_BANDS]; // (feather, strength, frequency, colorAttenuation)\nuniform float uNoiseBandParaPow[MAX_NOISE_BANDS];\n\nvarying vec2 vUv;\nvarying vec3 vWorldPos;\nvarying vec3 vViewDir;\n#ifdef DEPTH_TINT\nuniform float uDepthTint;\nuniform vec3 uDepthTintColor;\nvarying vec4 vClipPosition; // clip-space depth (written by the vertex shader for both programs)\n#endif\n#ifdef EDGE_FEATHER\nuniform float uEdgeFeather; // ribbon long-edge softness (only when it differs from the 0.1 default)\n#endif\n#ifdef POINTER_FX\nuniform float uPointerThin; // 0..1 local translucency near the cursor\nuniform float uPointerHue; // degrees, local hue rotation near the cursor\nuniform float uPointerLighten; // -1..1 local brightness lift near the cursor\nvarying float vPointerFall; // falloff × presence, written by the vertex shader\n#endif\n\n// Cheap value hash for the optional grain overlay (distinct from the simplex hash).\nfloat grainHash(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }\n\nfloat parabola(float x, float k){ return pow(4.0 * x * (1.0 - x), k); }\nfloat mapLinear(float v, float a, float b, float c, float d){ return c + (v - a) * (d - c) / (b - a); }\n\n${colorFns}\n\n// Striations: a subtle high-frequency simplex-noise grain ADDED to the\n// colour — colour-matched (weaker where blue is high), only near folds (crease), and\n// concentrated toward the ends (parabola). Blends in rather than reading as hard lines.\nvec3 surfaceStreaks(vec2 uv, vec3 color, float crease){\n float strength = uFiberStrength; // default 0.2\n float freq = uFiberCount; // default 600\n float colorAtten = 0.9;\n float paraPow = 3.0;\n // Noise bands: inside each rectangular uv region the\n // fiber params are overridden, so the streaks vary per region instead of uniform.\n for (int i = 0; i < MAX_NOISE_BANDS; i++) {\n if (i >= uNumNoiseBands) break;\n vec4 b = uNoiseBandBounds[i];\n vec4 prm = uNoiseBandParams[i];\n float feather = max(prm.x, 1.0e-4);\n float blend =\n smoothstep(b.x - feather, b.x, uv.x) * (1.0 - smoothstep(b.y, b.y + feather, uv.x)) *\n smoothstep(b.z - feather, b.z, uv.y) * (1.0 - smoothstep(b.w, b.w + feather, uv.y));\n strength = mix(strength, prm.y, blend);\n freq = mix(freq, prm.z, blend);\n colorAtten = mix(colorAtten, prm.w, blend);\n paraPow = mix(paraPow, uNoiseBandParaPow[i], blend);\n }\n // The high frequency runs along uv.x (the ribbon's length) so the streaks read as\n // fine lengthwise fibers; end-weighted by 1 - parabola(uv.x).\n float p = 1.0 - parabola(uv.x, paraPow);\n float n0 = simplexNoise(vec2(uv.x * 0.1, uv.y * 0.5));\n float n1 = simplexNoise(vec2(uv.x * (freq + freq * 0.5 * n0), uv.y * 4.0 * n0));\n n1 = mapLinear(n1, -1.0, 1.0, 0.0, 1.0);\n color += n1 * strength * (1.0 - color.b * colorAtten) * crease * p;\n return color;\n}\n\nvoid main(){\n // crease: a foreshortening / fold detector from the screen-space uv derivative.\n // It drives BOTH the roundness shading and where the streaks appear — this is what\n // gives the wave its thickness without any normal-based lighting.\n float crease = dFdy(vUv).y * uResolution.y * uCreaseLight;\n crease = clamp(mapLinear(crease, -1.0, 1.0, 0.0, 1.0), 0.0, 1.0);\n crease = pow(crease, uCreaseSharpness);\n crease = clamp(smoothstep(0.0, uCreaseSoftness, crease), 0.0, 1.0);\n\n // Debug visualisations (dev): 1 = crease value, 2 = derivative surface normal.\n if (uDebug > 0.5) {\n if (uDebug < 1.5) { gl_FragColor = vec4(vec3(crease), 1.0); return; }\n vec3 dn = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n gl_FragColor = vec4(dn * 0.5 + 0.5, 1.0); return;\n }\n\n // Colour: sample the baked 2D palette texture, or fall back to the procedural 1-D\n // gradient (see waveBaseColor).\n vec3 col = waveBaseColor(vUv);\n col = surfaceStreaks(vUv, col, crease);\n col = applyColorGrade(col);\n\n#ifdef POINTER_FX\n // Local hue rotation + brightness lift near the cursor (both fade out with vPointerFall).\n col = hueShift(col, radians(uPointerHue) * vPointerFall);\n col *= 1.0 + uPointerLighten * vPointerFall;\n#endif\n\n // Iridescence: a thin-film / holographic hue that shifts with view angle. Reuses the same\n // camera-facing ratio as roundness (recomputed here, since roundness may be off): grazing parts\n // of the ribbon (low facing) shift hue most, so the colour flows as the ribbon curves. Skipped\n // at 0, so the compiled result is unchanged when off.\n if (uIridescence > 0.001) {\n vec3 iridN = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n float iridFacing = abs(dot(iridN, normalize(vViewDir)));\n col = hueShift(col, (1.0 - iridFacing) * uIridescence * PI);\n }\n\n // Sheen: lift the flat (low-crease) areas toward white. This is\n // pose-dependent (it keys off dFdy(uv.y)), so we keep it gentle and add a robust term.\n col += (1.0 - crease) * 0.25 * uSheen;\n\n // Pose-robust roundness: shade by the camera-facing ratio of the derivative surface\n // normal so the ribbon reads as a rounded, grabbable solid from any angle. Grazing\n // edges darken into shadow (defining the rounded form), the body keeps its full colour,\n // and the most face-on sliver catches a soft highlight. uRoundness = strength.\n if (uRoundness > 0.001) {\n vec3 volN = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n float facing = abs(dot(volN, normalize(vViewDir))); // 1 = facing camera, 0 = edge-on\n col *= mix(1.0 - 0.6 * uRoundness, 1.0, facing); // deepen grazing edges → solid form\n col += smoothstep(0.65, 1.0, facing) * uRoundness * 0.18; // soft highlight on the facing body\n }\n\n // Optional positionable lights (our feature) — additive & gentle, on top of the\n // base shading so the default look is preserved. A finely-subdivided mesh\n // keeps this derivative normal smooth.\n if (uNumLights > 0) {\n vec3 N = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n vec3 Vd = normalize(vViewDir);\n if (dot(N, Vd) < 0.0) N = -N;\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uNumLights) break;\n vec3 L = normalize(uLightPos[i] - vWorldPos);\n vec3 lc = uLightColor[i] * uLightIntensity[i];\n float diff = max(dot(N, L), 0.0);\n float spec = pow(max(dot(N, normalize(L + Vd)), 0.0), 28.0);\n col += col * diff * lc * 0.16 + spec * lc * 0.10;\n }\n }\n col *= 0.55 + clamp(uAmbient, 0.0, 1.0); // overall level; default 0.45 => x1.0 (neutral)\n\n#ifdef DEPTH_TINT\n // Depth tint: fade far fragments toward a colour so a multi-wave stack gains atmospheric\n // separation — near strands keep their colour, far ones recede. Reuses the clip-space depth the\n // wireframe theme fades with (clamp(z*6), where 1 = far).\n col = mix(col, uDepthTintColor, clamp(vClipPosition.z * 6.0, 0.0, 1.0) * uDepthTint);\n#endif\n\n if (uTexture > 0.001) col *= 1.0 + (grainHash(vUv * 850.0) - 0.5) * uTexture * 0.25;\n\n // Soft long edges + optional viewport-edge fade. The edge softness is the hardcoded 0.1 by\n // default (literal branch → byte-identical); EDGE_FEATHER swaps in the uEdgeFeather knob only\n // when it differs, so razor-crisp or vapor-soft edges are both reachable.\n#ifdef EDGE_FEATHER\n float ribEdge =\n smoothstep(0.0, uEdgeFeather, vUv.y) * (1.0 - smoothstep(1.0 - uEdgeFeather, 1.0, vUv.y));\n#else\n float ribEdge = smoothstep(0.0, 0.1, vUv.y) * (1.0 - smoothstep(0.9, 1.0, vUv.y));\n#endif\n float alpha = uOpacity * ribEdge;\n#ifdef POINTER_FX\n alpha *= clamp(1.0 - uPointerThin * vPointerFall, 0.0, 1.0); // solid: local translucency\n#endif\n if (uEdgeFade > 0.001) {\n vec2 sc = gl_FragCoord.xy / max(uResolution, vec2(1.0));\n float vig =\n smoothstep(0.0, uEdgeFade, sc.x) * (1.0 - smoothstep(1.0 - uEdgeFade, 1.0, sc.x)) *\n smoothstep(0.0, uEdgeFade, sc.y) * (1.0 - smoothstep(1.0 - uEdgeFade, 1.0, sc.y));\n alpha *= vig;\n }\n\n // Deep \"squared\" hero colour: formerly done by a framebuffer-squaring blend that REPLACED the\n // destination (punching holes at soft edges / where waves overlap). Squaring here + normal\n // premultiplied compositing (see applyBlendMode) keeps the deep colour and blends correctly.\n col = clamp(col, 0.0, 1.0);\n // Square colour AND alpha so the soft ribbon edges keep the crisp, thin feather of the original\n // squared-blend look — but now composited (premultiplied) rather than replace-blended, so they\n // no longer punch holes. Over an opaque background alpha² still resolves to fully opaque.\n if (uSquared > 0.5) { col *= col; alpha *= alpha; }\n gl_FragColor = vec4(col, alpha);\n#ifdef PREMULTIPLIED_ALPHA\n gl_FragColor.rgb *= gl_FragColor.a;\n#endif\n}\n`;\n\n// ---- Wireframe \"thin-line\" theme ----\n// The same wave geometry, but instead of a solid surface the colour is carved into fine\n// vertical lines (abs(sin(uv.x * lineAmount))) whose thickness scales with the screen-\n// space uv derivative, then mixed line<->background with a depth fade. Used by the dark\n// hero preset. hueShift takes degrees (radians() here) to match the light shader.\nexport const lineFragmentShader = /* glsl */ `\n#define MAX_COLORS ${MAX_COLORS}\n#define MAX_MESH_POINTS ${MAX_MESH_POINTS}\n#define PI 3.14159265359\n\n${simplex2d}\n\n${colorUniforms}\nuniform float uLineAmount; // default 425\nuniform float uLineThickness; // default 1\nuniform float uLineDerivativePower; // default 0.95\nuniform float uMaxWidth; // default 1232\n// Cross-wise rungs (optional) — behind RUNGS so a wave without them compiles the same program.\n#ifdef RUNGS\nuniform float uRungAmount; // frequency across the ribbon (rungs ≈ amount / π)\nuniform float uRungThickness; // rung width in pixels\n#endif\nuniform vec3 uClearColor; // = page background colour (shown between the lines)\n\nvarying vec2 vUv;\nvarying vec4 vClipPosition;\n#ifdef POINTER_FX\nuniform float uPointerThin; // 0..1 — strands taper to hairlines near the cursor\nuniform float uPointerHue; // degrees, local hue rotation near the cursor\nuniform float uPointerLighten; // -1..1 local brightness lift near the cursor\nvarying float vPointerFall; // falloff × presence, written by the vertex shader\n#endif\n\n${colorFns}\n\nvoid main(){\n // Same 2D palette sample + colour ops as the solid theme.\n vec3 color = applyColorGrade(waveBaseColor(vUv));\n\n#ifdef POINTER_FX\n color = hueShift(color, radians(uPointerHue) * vPointerFall);\n color *= 1.0 + uPointerLighten * vPointerFall;\n#endif\n\n // Carve into fine vertical lines; thickness from the screen-space uv derivative.\n vec2 dy = dFdy(vUv);\n float lineThickness = uLineThickness * pow(abs(dy.x * uMaxWidth), uLineDerivativePower);\n#ifdef POINTER_FX\n lineThickness *= clamp(1.0 - uPointerThin * vPointerFall, 0.0, 1.0); // wireframe: taper strands\n#endif\n float a = abs(sin(vUv.x * uLineAmount));\n a = smoothstep(lineThickness, 0.0, a);\n\n#ifdef RUNGS\n // Rungs: the same carve at constant uv.y instead of uv.x, so this family runs ACROSS the ribbon\n // where the one above runs along it — together they read as a ladder. Width comes from fwidth()\n // rather than the lengthwise term's dFdy(vUv).x, which is the derivative of the wrong axis for\n // this direction: |sin| climbs by ~uRungAmount·fwidth(vUv.y) per pixel, so scaling by that keeps\n // a rung uRungThickness pixels wide at any zoom or ribbon scale.\n float rung = abs(sin(vUv.y * uRungAmount));\n a = max(a, smoothstep(uRungThickness * uRungAmount * fwidth(vUv.y), 0.0, rung));\n#endif\n\n // Depth fade: the wave recedes into the background colour with depth. Watch the\n // argument order: clamp(0.0, 1.0, z*6) is a swapped-args trap — it clamps the\n // constant 0.0 into [1.0, z*6], i.e. min(1.0, z*6), which (with our ortho clip.z\n // range) collapses the whole wave to the background. The correct clamp(z*6, 0, 1)\n // gives the proper subtle far-end fade and thin-line look.\n float depthFade = clamp(vClipPosition.z * 6.0, 0.0, 1.0);\n color = mix(uClearColor, color, a * (1.0 - depthFade));\n if (uSquared > 0.5) color *= color; // deep \"squared\" look, now composited not replace-blended\n gl_FragColor = vec4(color, uOpacity);\n#ifdef PREMULTIPLIED_ALPHA\n gl_FragColor.rgb *= gl_FragColor.a;\n#endif\n}\n`;\n\n// ---- Post pass: viewport-edge soft-focus blur + dither grain ----\n\nexport const postVertexShader = /* glsl */ `\nvarying vec2 vUv;\nvoid main(){\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}\n`;\n\nexport const postFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform vec2 uResolution;\nuniform float uBlurAmount;\nuniform int uBlurSamples;\nuniform float uGrainAmount;\nuniform float uTime;\nvarying vec2 vUv;\n\nfloat random2(vec2 st){ return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453); }\n\n// Angular (spin) blur: rotate the sample coord around the centre and\n// accumulate — a tangential smear that grows toward the edges. Carries alpha so a\n// transparent background survives the post pass.\nvec4 blurAngular(sampler2D tex, vec2 uv, float angle, int samples){\n vec4 total = vec4(0.0);\n vec2 coord = uv - 0.5;\n float dist = 1.0 / float(samples);\n vec2 dir = vec2(cos(angle * dist), sin(angle * dist));\n mat2 rot = mat2(dir.x, dir.y, -dir.y, dir.x);\n for (int i = 0; i < 64; i++){\n if (i >= samples) break;\n total += texture2D(tex, coord + 0.5);\n coord = coord * rot; // row-vector order (coord * rot) sets the spin direction\n }\n return total * dist;\n}\n\nvoid main(){\n vec4 sceneColor = texture2D(tDiffuse, vUv);\n vec4 blurColor = blurAngular(tDiffuse, vUv, uBlurAmount, uBlurSamples);\n // blurPower: keep a sharp band weighted to the middle, blurring toward top & bottom.\n float blurPower = smoothstep(0.0, 0.7, vUv.y) - smoothstep(0.2, 1.0, vUv.y);\n vec4 color = mix(blurColor, sceneColor, blurPower);\n // Static film grain: keyed off gl_FragCoord only (no uTime), so it doesn't flicker.\n color.rgb += mix(uGrainAmount, -uGrainAmount, random2(gl_FragCoord.xy * 0.01)) * (4.0 / 255.0);\n gl_FragColor = color; // preserve alpha → transparent background works\n}\n`;\n\n// ---- Post pass: ordered (Bayer) dithering ----\n//\n// DERIVED FROM @paper-design/shaders `image-dithering` (https://github.com/paper-design/shaders,\n// Apache-2.0 — see THIRD-PARTY-NOTICES.md). The Bayer matrices, getBayerValue, and the brightness /\n// luminance-quantization / hue-preserving \"original colours\" recolour are paper's. Adapted to a\n// post pass: samples the composited scene (tDiffuse) at full-frame vUv instead of paper's sized/fit\n// u_image UV, drops the frame/aspect machinery, fixes the 8x8 matrix (paper's default), and gates\n// via uDitherStrength. The int[] arrays + dynamic indexing compile because three builds\n// ShaderMaterials as \"#version 300 es\". Runs AFTER OutputPass, so it dithers display-space colour;\n// keyed off gl_FragCoord/tDiffuse only (no uTime) → deterministic, friendly to pixel-digest checks.\nexport const ditherFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform vec2 uResolution;\nuniform float uDitherStrength; // 0..1 mix back toward the original\nuniform float uDitherScale; // pixel-block size in device px (paper: u_pxSize)\nuniform float uDitherSteps; // quantization levels (paper: u_colorSteps)\nvarying vec2 vUv;\n\nconst int bayer2x2[4] = int[4](0, 2, 3, 1);\nconst int bayer4x4[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);\nconst int bayer8x8[64] = int[64](\n 0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26,\n 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22,\n 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25,\n 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21\n);\nfloat getBayerValue(vec2 uv, int size){\n ivec2 pos = ivec2(fract(uv / float(size)) * float(size));\n int index = pos.y * size + pos.x;\n if (size == 2) return float(bayer2x2[index]) / 4.0;\n else if (size == 4) return float(bayer4x4[index]) / 16.0;\n else if (size == 8) return float(bayer8x8[index]) / 64.0;\n return 0.0;\n}\n\nvoid main(){\n float pxSize = max(uDitherScale, 1.0);\n vec2 pxSizeUV = gl_FragCoord.xy / pxSize;\n vec2 sampleUV = (floor(gl_FragCoord.xy / pxSize) + 0.5) * pxSize / max(uResolution, vec2(1.0));\n vec4 image = texture2D(tDiffuse, sampleUV);\n\n float lum = dot(vec3(0.2126, 0.7152, 0.0722), image.rgb);\n float colorSteps = max(floor(uDitherSteps), 1.0);\n\n float dithering = getBayerValue(pxSizeUV, 8) - 0.5; // paper's default 8x8 ordered screen\n float brightness = clamp(lum + dithering / colorSteps, 0.0, 1.0);\n brightness = mix(0.0, brightness, image.a);\n float quantLum = floor(brightness * colorSteps + 0.5) / colorSteps;\n\n // paper's \"original colours\" path: keep the source hue, quantize luminance.\n vec3 color = image.rgb / max(lum, 0.001) * quantLum;\n float quantAlpha = floor(image.a * colorSteps + 0.5) / colorSteps;\n float opacity = mix(quantLum, 1.0, quantAlpha);\n\n gl_FragColor = mix(image, vec4(color, opacity), clamp(uDitherStrength, 0.0, 1.0));\n}\n`;\n\n// ---- Post pass: innerLight (volumetric light streaks) — another \"layered\" post shader ----\n//\n// Radial light-scattering (à la GPU Gems 3): from each pixel, march toward a light point and\n// accumulate the wave's own brightness (weighted by alpha, so only opaque pixels emit), then add\n// the streaks back. Runs in the scene zone so it scatters the raw, pre-tone-map wave like bloom.\nexport const innerLightFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uInnerLight; // 0..1 strength of the added light\nuniform float uInnerLightDensity; // ray length / spread\nuniform float uInnerLightDecay; // per-sample falloff (<1)\nuniform vec2 uInnerLightCenter; // light source, UV (0..1)\nvarying vec2 vUv;\n\nconst int LIGHT_SAMPLES = 24;\n\nfloat luma(vec3 c){ return dot(c, vec3(0.2126, 0.7152, 0.0722)); }\n\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n vec2 delta = (vUv - uInnerLightCenter) * (uInnerLightDensity / float(LIGHT_SAMPLES));\n vec2 coord = vUv;\n float decay = 1.0;\n vec3 rays = vec3(0.0);\n for (int i = 0; i < LIGHT_SAMPLES; i++){\n coord -= delta;\n vec4 s = texture2D(tDiffuse, coord);\n rays += s.rgb * s.a * decay; // only opaque (wave) pixels emit light\n decay *= uInnerLightDecay;\n }\n rays /= float(LIGHT_SAMPLES);\n vec3 outc = src.rgb + rays * uInnerLight;\n float outA = max(src.a, luma(rays) * uInnerLight); // shafts stay visible over the transparent bg\n gl_FragColor = vec4(outc, clamp(outA, 0.0, 1.0));\n}\n`;\n\n// ---- Post pass: halftone (rotated dot screen) ----\n//\n// DERIVED FROM @paper-design/shaders `halftone-dots` (https://github.com/paper-design/shaders,\n// Apache-2.0 — see THIRD-PARTY-NOTICES.md). Ports the \"classic\" dot type + \"original colours\" path:\n// paper's getCircle (dot radius ← 1 − luminance, fwidth-antialiased) and sigmoid-contrast luminance,\n// sampled once per cell centre. Adapted to a post pass — samples the composited scene (tDiffuse)\n// instead of paper's sized u_image, drops the gooey/holes/soft dot types, the diagonal grid and the\n// grain layers, and composites transparent between dots. Contrast/radius fixed at paper's defaults.\nexport const halftoneFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform vec2 uResolution;\nuniform float uHalftone; // 0..1 mix\nuniform float uHalftoneCell; // dot cell size in device px (paper: u_size)\nuniform float uHalftoneAngle; // screen rotation (radians, paper: u_rotation)\nvarying vec2 vUv;\n\nfloat sigmoid(float x, float k){ return 1.0 / (1.0 + exp(-k * (x - 0.5))); }\n// paper's classic dot: radius grows as the sampled cell darkens (1 - lum), soft edge via fwidth.\nfloat getCircle(vec2 uv, float lum, float baseR){\n float r = mix(0.25 * baseR, 0.0, lum);\n float d = length(uv - 0.5);\n float aa = fwidth(d);\n return 1.0 - smoothstep(r - aa, r + aa, d);\n}\n\nvoid main(){\n float ca = cos(uHalftoneAngle);\n float sa = sin(uHalftoneAngle);\n mat2 rot = mat2(ca, sa, -sa, ca);\n float cell = max(uHalftoneCell, 2.0);\n vec2 gridPx = rot * gl_FragCoord.xy; // rotate the screen into the dot grid\n vec2 cellId = floor(gridPx / cell);\n vec2 inCell = fract(gridPx / cell); // position within the cell (0..1)\n vec2 centrePx = transpose(rot) * ((cellId + 0.5) * cell); // cell centre, back in screen px\n vec4 tex = texture2D(tDiffuse, centrePx / max(uResolution, vec2(1.0)));\n\n float k = 2.0; // sigmoid contrast (paper default)\n vec3 c = vec3(sigmoid(tex.r, k), sigmoid(tex.g, k), sigmoid(tex.b, k));\n float lum = dot(vec3(0.2126, 0.7152, 0.0722), c);\n lum = mix(1.0, lum, tex.a);\n float dot = getCircle(inCell, lum, 1.3); // baseR 1.3 ≈ paper original-colours default\n vec4 dots = vec4(tex.rgb, tex.a * dot); // wave-coloured dots, transparent between\n gl_FragColor = mix(texture2D(tDiffuse, vUv), dots, clamp(uHalftone, 0.0, 1.0));\n}\n`;\n\n// ---- Post pass: heatmap (map luminance → thermal palette) — a finish-zone filter ----\nexport const heatmapFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uHeatmap; // 0..1 mix\nvarying vec2 vUv;\nvec3 heat(float t){\n t = clamp(t, 0.0, 1.0);\n vec3 c = mix(vec3(0.0, 0.0, 0.4), vec3(0.0, 0.6, 1.0), smoothstep(0.0, 0.25, t));\n c = mix(c, vec3(0.0, 1.0, 0.4), smoothstep(0.25, 0.5, t));\n c = mix(c, vec3(1.0, 1.0, 0.0), smoothstep(0.5, 0.75, t));\n c = mix(c, vec3(1.0, 0.1, 0.0), smoothstep(0.75, 1.0, t));\n return c;\n}\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n float l = dot(src.rgb, vec3(0.299, 0.587, 0.114));\n gl_FragColor = vec4(mix(src.rgb, heat(l), clamp(uHeatmap, 0.0, 1.0)), src.a);\n}\n`;\n\n// ---- Post pass: paper texture (fibrous substrate shading) — a finish-zone overlay ----\nexport const paperTextureFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uPaper; // 0..1 strength\nuniform float uPaperScale; // grain scale\nvarying vec2 vUv;\nfloat h21(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n vec2 p = gl_FragCoord.xy / max(uPaperScale, 0.5);\n float fiber = h21(floor(p)) * 0.5 + h21(floor(p * vec2(0.3, 3.0))) * 0.5; // directional fibers\n float tex = mix(fiber, h21(gl_FragCoord.xy), 0.3); // + fine speckle\n float shade = 1.0 - (tex - 0.5) * 0.35;\n gl_FragColor = vec4(src.rgb * mix(1.0, shade, clamp(uPaper, 0.0, 1.0)), src.a);\n}\n`;\n\n// ---- Post pass: CMYK halftone (four rotated dot screens) — a finish-zone filter ----\nexport const halftoneCmykFragmentShader = /* glsl */ `\nuniform sampler2D tDiffuse;\nuniform float uHalftoneCmyk; // 0..1 mix\nuniform float uHalftoneCmykCell; // dot cell size in device px\nvarying vec2 vUv;\n// One rotated halftone dot screen for a channel value.\nfloat dotScreen(vec2 coord, float value, float angle, float cell){\n float ca = cos(angle);\n float sa = sin(angle);\n vec2 r = mat2(ca, sa, -sa, ca) * coord;\n vec2 c = fract(r / max(cell, 2.0)) - 0.5;\n float radius = sqrt(clamp(value, 0.0, 1.0)) * 0.5;\n return smoothstep(radius, radius - 0.06, length(c));\n}\nvoid main(){\n vec4 src = texture2D(tDiffuse, vUv);\n float k = 1.0 - max(max(src.r, src.g), src.b); // RGB → CMYK\n float invK = max(1.0 - k, 1e-3);\n float cyan = (1.0 - src.r - k) / invK;\n float mag = (1.0 - src.g - k) / invK;\n float yel = (1.0 - src.b - k) / invK;\n vec2 coord = gl_FragCoord.xy;\n float cell = uHalftoneCmykCell;\n float dc = dotScreen(coord, cyan, 1.309, cell); // 75°\n float dm = dotScreen(coord, mag, 0.262, cell); // 15°\n float dy = dotScreen(coord, yel, 0.0, cell); // 0°\n float dk = dotScreen(coord, k, 0.785, cell); // 45°\n // Subtractive: cyan ink absorbs red, magenta absorbs green, yellow absorbs blue, black absorbs all.\n vec3 outc = vec3(1.0) - vec3(dc, 0.0, 0.0) - vec3(0.0, dm, 0.0) - vec3(0.0, 0.0, dy) - vec3(dk);\n outc = clamp(outc, 0.0, 1.0);\n gl_FragColor = vec4(mix(src.rgb, outc, clamp(uHalftoneCmyk, 0.0, 1.0)), src.a);\n}\n`;\n"],"mappings":";;;;;;;;;;;;;;;AAsBA,MAAM,YAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoC7B,MAAM,gBAA2B;;;;;;;;;;;;;;;;;;;;;;;;AA2BjC,MAAM,WAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyF5B,MAAa,eAA0B;EACrC,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAwHuC,IAAA,CAAA,QAAQ,CAAC,EAAE;;YAElC,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkHvC,MAAa,iBAA4B;;;;;;;EAOvC,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+Cd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4JX,MAAa,qBAAgC;;;;;EAK3C,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;EAqBd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CX,MAAa,mBAA8B;;;;;;;AAQ3C,MAAa,qBAAgC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkD7C,MAAa,uBAAkC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqD/C,MAAa,2BAAsC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCnD,MAAa,yBAAoC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCjD,MAAa,wBAAmC;;;;;;;;;;;;;;;;;;AAoBhD,MAAa,6BAAwC;;;;;;;;;;;;;;;AAiBrD,MAAa,6BAAwC"}
@@ -2,7 +2,6 @@ import { StudioConfig } from "../config/model.js";
2
2
  import { WaveRenderer } from "../renderer/WaveRenderer.js";
3
3
  import { core_loader_d_exports } from "../core-loader.js";
4
4
  import { PosterFit } from "./poster.js";
5
-
6
5
  //#region src/shell/createWave.d.ts
7
6
  /** Why the shell showed the poster instead of a live wave. */
8
7
  type FallbackReason = "no-webgl" | "reduced-motion" | "save-data" | "context-lost" | "load-error";