@wave3d/core 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/config/model.d.ts +209 -6
  2. package/dist/config/model.js +85 -3
  3. package/dist/config/model.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/presets.js +295 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveGeometry.js +21 -0
  9. package/dist/renderer/WaveGeometry.js.map +1 -1
  10. package/dist/renderer/WaveRenderer.d.ts +62 -0
  11. package/dist/renderer/WaveRenderer.js +349 -10
  12. package/dist/renderer/WaveRenderer.js.map +1 -1
  13. package/dist/renderer/WaveRendererGPU.js +32 -2
  14. package/dist/renderer/WaveRendererGPU.js.map +1 -1
  15. package/dist/renderer/interaction.js +12 -0
  16. package/dist/renderer/interaction.js.map +1 -1
  17. package/dist/renderer/particleField.js +26 -2
  18. package/dist/renderer/particleField.js.map +1 -1
  19. package/dist/renderer/particleFieldGPU.js +6 -0
  20. package/dist/renderer/particleFieldGPU.js.map +1 -1
  21. package/dist/renderer/shaders.js +748 -13
  22. package/dist/renderer/shaders.js.map +1 -1
  23. package/dist/renderer/tsl/dissolve.js +56 -0
  24. package/dist/renderer/tsl/dissolve.js.map +1 -0
  25. package/dist/renderer/tsl/particleMaterial.js +46 -8
  26. package/dist/renderer/tsl/particleMaterial.js.map +1 -1
  27. package/dist/renderer/tsl/uniforms.js +40 -1
  28. package/dist/renderer/tsl/uniforms.js.map +1 -1
  29. package/dist/renderer/tsl/waveMaterial.js +275 -26
  30. package/dist/renderer/tsl/waveMaterial.js.map +1 -1
  31. package/dist/renderer/tsl/waveShape.js +31 -10
  32. package/dist/renderer/tsl/waveShape.js.map +1 -1
  33. package/dist/renderer/wavePath.js +189 -0
  34. package/dist/renderer/wavePath.js.map +1 -0
  35. package/dist/shell/createWave.d.ts +23 -3
  36. package/dist/shell/createWave.js +5 -4
  37. package/dist/shell/createWave.js.map +1 -1
  38. package/dist/shell/probe.d.ts +28 -0
  39. package/dist/shell/probe.js +58 -11
  40. package/dist/shell/probe.js.map +1 -1
  41. package/dist/standalone/wave3d.standalone.js +3401 -2108
  42. package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
  43. package/dist/standalone.d.ts +2 -2
  44. package/dist/standalone.js +2 -2
  45. package/dist/studio/StudioWaveRenderer.d.ts +115 -8
  46. package/dist/studio/StudioWaveRenderer.js +588 -14
  47. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  48. package/dist/studio/index.d.ts +2 -2
  49. package/dist/studio/index.js.map +1 -1
  50. package/dist/studio/randomize.js +0 -1
  51. package/dist/studio/randomize.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/wave3d/SKILL.md +142 -5
@@ -1 +1 @@
1
- {"version":3,"file":"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\n// The shared wave-shape deform (expStep + rotationMatrix + waveShape), used by BOTH the wave\n// vertex shader and the particle shed emitter, so the ribbon and the dust it sheds ride ONE deform.\nconst waveShapeChunk = /* glsl */ `\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\n// The wave SHAPE deform, shared by the wave vertex shader (below) and the particle SHED emitter\n// (particleVertexShader): a base hairpin position + its uv → the displaced / helixed / twisted /\n// fanned LOCAL position, plus the three twist matrices (the pointer field reads them). Every branch\n// sits behind the SAME #ifdef gates as the code it replaces, so a given compiled program is\n// byte-identical to the former inline version. t / loopOff are the linear / orbit time the\n// caller computed; only the one selected by LOOP_MOTION is read (the other is a dead argument).\nstruct WaveShape { vec3 pos; mat4 rotA; mat4 rotB; mat4 rotC; };\nWaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){\n // Displacement lifts Y by simplex noise of the (x,z) position.\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 (loop-orbit shared so it stays periodic).\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 periodic sweep the three twists (monotone falloffs) can't reach. Runs AFTER the\n // displacement (so the noise still samples undeformed pos) and BEFORE the twist (so they compose).\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; the TWIST_MOTION variant modulates it with simplex noise\n // indexed along the ribbon (uv.y) so the twist breathes over time.\n float twistXFreq = uTwFreqX;\n#ifdef TWIST_MOTION\n#ifdef LOOP_MOTION\n float twistXNoise = simplexNoise(vec2(uv.y * 2.0, 0.0) + loopOff);\n#else\n float twistXNoise = simplexNoise(vec2(uv.y * 2.0, t));\n#endif\n twistXFreq = uTwFreqX - twistXNoise * 0.1;\n#endif\n\n // Three-axis twist (see the falloff-axis note: rotA keys off uv.x/WIDTH, rotB/rotC off uv.y/LENGTH).\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 RADIAL\n // Radial fan: remap the ribbon to polar around the LOCAL origin so its LENGTH fans into a plume.\n // uv.x (folded WIDTH) → fan ANGLE across uRadialArc; uv.y (LENGTH) → RADIUS, so a constant-uv.x\n // combed fiber becomes a constant-angle radial spoke. mix(pos, fanned, 0) is identity → off is\n // byte-identical. (Placement is the wave's position transform — the fan has no separate pivot.)\n {\n float rAng = radians(uRadialCenter) + (clamp(uv.x, 0.0, 1.0) - 0.5) * radians(uRadialArc);\n float rRho = uRadialRadius + uv.y * 400.0 * uRadialSpread; // 400 = native ribbon length\n vec3 rEr = vec3(cos(rAng), sin(rAng), 0.0); // radial dir, in local X–Y (screen plane)\n vec3 rEt = vec3(-sin(rAng), cos(rAng), 0.0); // tangential\n vec3 fanned = rEr * rRho\n + rEt * (pos.z - ${RIBBON_Z_CENTER.toFixed(1)}) * 0.5\n + vec3(0.0, 0.0, pos.y);\n pos = mix(pos, fanned, clamp(uRadialAmount, 0.0, 1.0));\n }\n#endif\n\n WaveShape s;\n s.pos = pos;\n s.rotA = rotA;\n s.rotB = rotB;\n s.rotC = rotC;\n return s;\n}\n`;\n\n// The POINTER FIELD, shared by the wave vertex shader and the particle emitter (particleVertexShader)\n// exactly as waveShapeChunk shares the deform — so a wave's dust reacts to the cursor through the SAME\n// footprint, falloff and displacement its ribbon does instead of staying pinned to the un-poked\n// surface. The whole chunk is interpolated INSIDE `#ifdef POINTER_FX` in both callers, so a wave with\n// no interaction config compiles the exact same program as before (JS-side uniform entries are always\n// present — see makeUniforms — but three only uploads uniforms the compiled program declares).\n// Requires simplexNoise and the uDispFreqX / uDispFreqZ shape uniforms declared above.\nconst pointerFieldChunk = /* glsl */ `\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;\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\n// fall = screen falloff × presence (the wave's vPointerFall, which both fragment themes consume);\n// disp = the signed displacement along the surface's own up-axis, which the CALLER applies (the wave\n// in its local space, the dust through the wave's world matrix).\nstruct PointerHit { float fall; float disp; };\n\n// Sample the field for ONE point. ndc is that point's screen position; mvp the clip transform of the\n// space rotA/rotB/rotC and churnPos live in (the owning wave's local space); t / loopOff the caller's\n// linear / orbit time — only the one selected by LOOP_MOTION is read.\nPointerHit pointerField(vec2 ndc, mat4 mvp, mat4 rotA, mat4 rotB, mat4 rotC, vec3 churnPos,\n float t, vec2 loopOff){\n // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.\n vec2 dp = (ndc - 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)) * uPointerActive;\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 * fall\n * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0, churnPos.z * uDispFreqZ * 3.0) + loopOff * 4.0);\n#else\n float disp = uPointerAgitate * fall\n * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0 + t * 4.0, churnPos.z * uDispFreqZ * 3.0));\n#endif\n // Membrane push/pull: a smooth dome (fall is the falloff) that swells toward you (+ repel) or dents\n // away (− attract) at the cursor, riding along with the sprung field.\n disp += uPointerPush * fall;\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 * fall * 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((ndc - 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 PointerHit hit;\n hit.fall = fall;\n hit.disp = disp;\n return hit;\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\n// Radial fan (optional). Behind RADIAL so a wave without one compiles the exact same program (same\n// byte-identity contract as HELIX / POINTER_FX above).\n#ifdef RADIAL\nuniform float uRadialAmount; // 0..1 blend (0 = identity)\nuniform float uRadialArc; // fan spread, degrees\nuniform float uRadialSpread; // length → radius scale\nuniform float uRadialRadius; // source / inner radius\nuniform float uRadialCenter; // base angle, 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) — the shared chunk, gated so a wave with no interaction config\n// compiles the exact same program. The particle emitter interpolates the SAME chunk, so dust reacts\n// through one implementation of the footprint / falloff / displacement.\n#ifdef POINTER_FX\n${pointerFieldChunk}\nvarying float vPointerFall; // falloff × presence — consumed by both fragment themes\n#endif\n\n${waveShapeChunk}\n\nvoid main(){\n vUv = uv;\n#ifndef LOOP_MOTION\n float t = uTime * uSpeed + uSeed;\n vec2 loopOff = vec2(0.0); // unused under linear time; kept so waveShape's signature is uniform\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 float t = 0.0; // unused under loop time\n#endif\n\n // Deform the baked hairpin via the shared waveShape chunk (displacement + helix + twist + radial),\n // which also drives the particle shed emitter. It returns the deformed local pos + the twist\n // matrices the pointer field reads below.\n WaveShape ws = waveShape(position, uv, t, loopOff);\n vec3 pos = ws.pos;\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. The field itself lives in pointerFieldChunk, which the\n // particle emitter also calls — so dust reacts through this exact footprint and falloff.\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 PointerHit hit = pointerField(preClip.xy / max(preClip.w, 1.0e-6), mvp,\n ws.rotA, ws.rotB, ws.rotC, pos, t, loopOff);\n vPointerFall = hit.fall;\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) * ws.rotA) * ws.rotB) * ws.rotC).xyz;\n pos += dispAxis * hit.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; // softness of the ribbon's two ENDS (only when it differs from 0.1)\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 folded WIDTH — see WaveGeometry's UV AXES note),\n // packing many thin stripes across the cross-section while uv.y is barely scaled, so each\n // one stretches out into a fine LENGTHWISE fiber. 1 - parabola(uv.x) then weights them\n // toward the two long edges and away from the width centreline.\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 ribbon ENDS (it fades on vUv.y, the length) + optional viewport-edge fade. The edge\n // 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// LENGTHWISE strands (abs(sin(uv.x * lineAmount)) — uv.x is the folded width, so lineAmount\n// counts strands ACROSS the cross-section and each runs end to end) whose thickness scales\n// with the screen-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 lengthwise strands; 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\n// ---------------------------------------------------------------------------------------------\n// Particle field (additive dust / sparkle) — ONE per wave. A THREE.Points ShaderMaterial: every\n// particle's position + life is a pure function of uTime + baked per-particle attributes (aSeed / aRnd\n// / aUv), so the whole field is deterministic (timeOffset scrub / loopSeconds / paused all hold).\n// Every particle spawns on the OWNING wave's DEFORMED surface / edge (via the shared waveShape chunk,\n// riding the exact deform the ribbon uses) and drifts outward from the wave centre as it ages. The\n// wave's shape #defines (HELIX/RADIAL/…) are mirrored onto this material in configure().\n// ---------------------------------------------------------------------------------------------\nexport const particleVertexShader = /* glsl */ `\nattribute float aSeed;\nattribute vec4 aRnd;\nattribute vec2 aUv; // where this particle spawns on the ribbon (x = flank, y = along length; edge-biased at build)\n\nuniform float uTime, uLoopSeconds, uLife, uSize, uSizeJitter, uTwinkle, uPixelRatio;\nuniform float uPartSpeed;\nuniform vec3 uColor, uColor2, uCenter, uRight, uUp;\nuniform float uDrift, uRise, uSwirl, uWander;\n\n// The owning wave's shape, mirrored in configure() so the dust rides the SAME deform as the ribbon.\n// The HELIX/RADIAL uniform blocks are declared only when the matching #define is set.\n${simplex2d}\nuniform float uDispFreqX, uDispFreqZ, uDispAmount;\nuniform float uDetailFreq, uDetailAmount;\nuniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;\n#ifdef HELIX\nuniform float uHelixTurns, uHelixRadius, uHelixRoll, uHelixPhase;\n#endif\n#ifdef RADIAL\nuniform float uRadialAmount, uRadialArc, uRadialSpread, uRadialRadius, uRadialCenter;\n#endif\nuniform mat4 uShedModel; // the wave's matrixWorld (deformed LOCAL → world)\nuniform float uShedSpeed, uShedSeed;\n${waveShapeChunk}\n\n// The cursor. Same chunk the ribbon uses, mirrored onto this material in ParticleField.configure(),\n// and behind the same POINTER_FX gate — a wave with no hover field compiles the point program it\n// always did. uPartShove is the one particle-only knob (see the two samples in main).\n#ifdef POINTER_FX\n${pointerFieldChunk}\nuniform float uPartShove; // how hard the cursor shoves dust that has already drifted free (0 = off)\n#endif\n\nvarying float vAlpha;\nvarying vec3 vColor;\nvarying vec2 vDir; // screen-space motion direction (for the streak sprite)\n\nconst float TAU = 6.28318530718;\n\nvoid main(){\n // Deterministic life: age 0..1 from uTime + a per-particle seed. Advances once per loop period when\n // looping (so the whole field repeats seamlessly), else once per uLife seconds. uPartSpeed scales the\n // cadence (motion speed); under a loop it snaps to a whole number of cycles so the seam stays seamless.\n float cyc = max(1.0, floor(uPartSpeed + 0.5));\n float rate = (uLoopSeconds > 0.0) ? (uTime / uLoopSeconds * cyc) : (uTime * uPartSpeed / max(uLife, 0.001));\n float age = fract(rate + aSeed);\n float fade = sin(3.14159265 * age); // 0 at birth/death, 1 mid-life\n\n // Spawn on the owning wave's DEFORMED surface / edge at aUv (via the shared waveShape), then peel\n // outward from the wave centre as the particle ages — silk dissolving into glitter.\n float ts = uTime * uShedSpeed + uShedSeed;\n vec2 loopOff = vec2(0.0);\n#ifdef LOOP_MOTION\n float loopTheta = uTime * (TAU / uLoopSeconds) + uShedSeed;\n float loopR = uShedSpeed * uLoopSeconds * 0.159154943092;\n loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));\n ts = 0.0;\n#endif\n // Approximate the base hairpin point for this uv (length from uv.y; width centre), then deform it\n // exactly as the wave does. Good enough for dust — the fan / displacement dominate.\n vec3 base = vec3((aUv.y - 0.5) * 400.0, 0.0, ${RIBBON_Z_CENTER.toFixed(1)});\n WaveShape ws = waveShape(base, aUv, ts, loopOff);\n vec3 origin = (uShedModel * vec4(ws.pos, 1.0)).xyz;\n vec3 outward = normalize(origin - uCenter + vec3(1e-4));\n\n#ifdef POINTER_FX\n // WELD (applied below, once the mote's own motion is known). The ribbon displaces its surface by\n // pointerField() along its own post-twist up-axis; a mote sitting ON that surface has to take the\n // same ride, or the cursor's dome lifts the silk out from under its own glitter. Sampled at the\n // SPAWN point and carried through the local→world matrix exactly as the ribbon's own\n // pos += dispAxis * disp is, so the two land on the same place. Sampling at the spawn point also\n // leaves outward derived from the UNDISPLACED origin, so a poke never bends the drift direction.\n mat4 pMvp = projectionMatrix * viewMatrix * uShedModel;\n vec4 originClip = pMvp * vec4(ws.pos, 1.0);\n vec3 dispAxis = mat3(uShedModel) * (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;\n PointerHit weld = pointerField(originClip.xy / max(originClip.w, 1.0e-6), pMvp,\n ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);\n#endif\n\n vec3 p = origin + outward * age * uDrift + (aRnd.xyz - 0.5) * age * uDrift * 0.35;\n\n // Motion styles, each 0 = off, all riding age so they stay loop-safe (the age wrap is hidden by\n // fade→0 at birth/death). rise = screen-vertical buoyancy (embers up / snow down); swirl = orbit\n // around the wave centre in the screen plane; wander = curl-noise turbulence (fireflies / motes).\n p += uUp * age * uRise;\n // How far this mote travels away from its birth patch over a WHOLE life — the straight-line terms\n // plus, under swirl, the arc it sweeps at its own orbit radius. Only the pointer weld reads it\n // (0 for dust that merely clings to the surface, which is exactly the case age would get wrong),\n // so it is fenced like everything else the cursor drives.\n#ifdef POINTER_FX\n float span = abs(uDrift) * 1.35 + abs(uRise) + uWander;\n#endif\n if (uSwirl != 0.0) {\n vec3 nrm = cross(uRight, uUp);\n vec3 rel = p - uCenter;\n#ifdef POINTER_FX\n span += abs(uSwirl) * TAU * length(rel);\n#endif\n float rx = dot(rel, uRight), ry = dot(rel, uUp), rz = dot(rel, nrm);\n float a = age * uSwirl * TAU;\n float ca = cos(a), sa = sin(a);\n p = uCenter + uRight * (rx * ca - ry * sa) + uUp * (rx * sa + ry * ca) + nrm * rz;\n }\n if (uWander != 0.0) {\n vec2 wan = vec2(simplexNoise(vec2(aSeed * 17.0, age * 3.0)),\n simplexNoise(vec2(age * 3.0, aSeed * 23.0)));\n p += (uRight * wan.x + uUp * wan.y) * uWander;\n }\n\n#ifdef POINTER_FX\n // How attached to its birth patch this mote still is: 1 while it sits on the surface, 0 once it\n // has travelled a full life's worth away. Measured from the DISTANCE it actually moved rather than\n // from age, because dust with no drift / rise / swirl / wander never leaves the surface at all —\n // an age fade would quietly stop that dust from following the ribbon halfway through its life.\n float attach = span > 1.0e-4 ? 1.0 - clamp(length(p - origin) / span, 0.0, 1.0) : 1.0;\n p += dispAxis * (weld.disp * attach);\n // SHOVE: the exact complement. The same field sampled at the mote's OWN screen position, so the\n // cursor also pushes dust that has already left the surface — and a click ripple visibly blows\n // through the cloud instead of stopping dead at the ribbon. Uniform branch (warp-coherent), so\n // uPartShove 0 costs nothing.\n if (uPartShove != 0.0) {\n vec4 pClip = projectionMatrix * viewMatrix * vec4(p, 1.0);\n PointerHit shove = pointerField(pClip.xy / max(pClip.w, 1.0e-6), pMvp,\n ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);\n p += dispAxis * (shove.disp * (1.0 - attach) * uPartShove);\n }\n#endif\n\n float tw = 0.5 + 0.5 * sin((age * 9.0 + aSeed) * TAU); // loop-safe flicker (rides age)\n vAlpha = fade * mix(1.0, tw, clamp(uTwinkle, 0.0, 1.0));\n vColor = mix(uColor, uColor2, aRnd.w); // two-tone dust: per-particle blend of the two colours\n vDir = normalize(vec2(dot(outward, uRight), dot(outward, uUp)) + vec2(1e-4)); // outward, in screen space\n gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);\n // Orthographic camera → point size is constant in device pixels (no perspective depth divide).\n float jitter = 1.0 + uSizeJitter * (aSeed - 0.5) * 2.0;\n gl_PointSize = max(uSize * uPixelRatio * jitter * fade, 0.0);\n}\n`;\n\nexport const particleFragmentShader = /* glsl */ `\nprecision highp float;\nuniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak\nvarying float vAlpha;\nvarying vec3 vColor;\nvarying vec2 vDir;\n// User artwork (shape \"sprite\"), behind a define so a field without one compiles the exact same\n// program — and so the sampler only exists once a texture is actually bound to it. ONE texture is\n// shared by every particle in the field; see ParticleField.loadSprite for the rasterization.\n#ifdef PARTICLE_SPRITE\nuniform sampler2D uSprite;\n#endif\nvoid main(){\n#ifdef PARTICLE_SPRITE\n // gl_PointCoord's origin is the sprite's TOP-left with y running DOWN, so it has to be flipped or\n // every sprite draws upside down. The procedural shapes below never needed this — they are all\n // symmetric about y, which is exactly why the bug would have gone unnoticed.\n vec4 tex = texture2D(uSprite, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));\n float a = tex.a * vAlpha;\n if (a <= 0.0) discard;\n // Tinted by the dust colour so color / color2 keep working: white artwork takes the tint\n // exactly, coloured artwork multiplies it.\n gl_FragColor = vec4(vColor * tex.rgb, a);\n#else\n vec2 pc = gl_PointCoord - 0.5;\n float d = length(pc);\n int s = int(uShape + 0.5);\n float a;\n if (s == 1) { // soft: a diffuse gaussian blob (motes / pollen)\n a = exp(-d * d * 7.0);\n } else if (s == 2) { // ring: a hollow band (bubbles)\n a = smoothstep(0.09, 0.0, abs(d - 0.34));\n } else if (s == 3) { // star: a 4-point sparkle\n float ang = atan(pc.y, pc.x);\n float spike = pow(abs(cos(ang * 2.0)), 6.0);\n a = smoothstep(1.0, 0.0, d / (0.14 + 0.5 * spike));\n } else if (s == 4) { // streak: an elongated comet along the motion direction\n float along = dot(pc, vDir);\n float perp = dot(pc, vec2(-vDir.y, vDir.x));\n a = smoothstep(0.5, 0.0, length(vec2(along * 0.42, perp * 2.2)));\n } else { // glitter (0): the soft round additive disc\n a = smoothstep(0.5, 0.0, d);\n }\n a *= vAlpha;\n if (a <= 0.0) discard;\n gl_FragColor = vec4(vColor, a); // AdditiveBlending (src = SrcAlpha) → adds vColor·a\n#endif\n}\n`;\n"],"mappings":";;;;;;;;;;;;;;;AAsBA,MAAM,YAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoC7B,MAAM,gBAA2B;;;;;;;;;;;;;;;;;;;;;;;;AA2BjC,MAAM,WAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2F5B,MAAM,iBAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAiDiB,IAAA,CAAA,QAAQ,CAAC,EAAE;;YAElC,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAoCY,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;AAsB9D,MAAM,oBAA+B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiGrC,MAAa,eAA0B;EACrC,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAoCV,kBAAkB;;;;EAIlB,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0DjB,MAAa,iBAA4B;;;;;;;EAOvC,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+Cd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgKX,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CrD,MAAa,uBAAkC;;;;;;;;;;;;EAY7C,UAAU;;;;;;;;;;;;EAYV,eAAe;;;;;;EAMf,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iDA+B6C,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+E5E,MAAa,yBAAoC"}
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\";\nimport { PATH_ROWS, PATH_SAMPLES } from \"./wavePath\";\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\n// The shared wave-shape deform (expStep + rotationMatrix + waveShape), used by BOTH the wave\n// vertex shader and the particle shed emitter, so the ribbon and the dust it sheds ride ONE deform.\nconst waveShapeChunk = /* glsl */ `\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\n// The wave SHAPE deform, shared by the wave vertex shader (below) and the particle SHED emitter\n// (particleVertexShader): a base hairpin position + its uv → the displaced / helixed / twisted /\n// fanned LOCAL position, plus the three twist matrices (the pointer field reads them). Every branch\n// sits behind the SAME #ifdef gates as the code it replaces, so a given compiled program is\n// byte-identical to the former inline version. t / loopOff are the linear / orbit time the\n// caller computed; only the one selected by LOOP_MOTION is read (the other is a dead argument).\nstruct WaveShape { vec3 pos; mat4 rotA; mat4 rotB; mat4 rotC; };\nWaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){\n // Displacement lifts Y by simplex noise of the (x,z) position.\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 (loop-orbit shared so it stays periodic).\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\n#ifdef HELIX\n // Helix — the periodic sweep the three twists (monotone falloffs) can't reach. Runs AFTER the\n // displacement (so the noise still samples undeformed pos) and BEFORE the twist (so they compose).\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; the TWIST_MOTION variant modulates it with simplex noise\n // indexed along the ribbon (uv.y) so the twist breathes over time.\n float twistXFreq = uTwFreqX;\n#ifdef TWIST_MOTION\n#ifdef LOOP_MOTION\n float twistXNoise = simplexNoise(vec2(uv.y * 2.0, 0.0) + loopOff);\n#else\n float twistXNoise = simplexNoise(vec2(uv.y * 2.0, t));\n#endif\n twistXFreq = uTwFreqX - twistXNoise * 0.1;\n#endif\n\n // Three-axis twist (see the falloff-axis note: rotA keys off uv.x/WIDTH, rotB/rotC off uv.y/LENGTH).\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\n#ifdef RADIAL\n // Radial fan: remap the ribbon to polar around the LOCAL origin so its LENGTH fans into a plume.\n // uv.x (folded WIDTH) → fan ANGLE across uRadialArc; uv.y (LENGTH) → RADIUS, so a constant-uv.x\n // combed fiber becomes a constant-angle radial spoke. mix(pos, fanned, 0) is identity → off is\n // byte-identical. (Placement is the wave's position transform — the fan has no separate pivot.)\n {\n // Swirl: let the ANGLE advance along the band as well as across it, so the arm curves around the\n // throat into a spiral instead of running straight out from it. Radius already grows with uv.y,\n // so angle gaining with uv.y too is exactly what makes a spiral — and it is the one thing a fan\n // cannot do otherwise, since its angle comes from uv.x alone.\n float rAng = radians(uRadialCenter) + (clamp(uv.x, 0.0, 1.0) - 0.5) * radians(uRadialArc)\n + uv.y * radians(uRadialSwirl);\n float rRho = uRadialRadius + uv.y * 400.0 * uRadialSpread; // 400 = native ribbon length\n vec3 rEr = vec3(cos(rAng), sin(rAng), 0.0); // radial dir, in local X–Y (screen plane)\n vec3 rEt = vec3(-sin(rAng), cos(rAng), 0.0); // tangential\n vec3 fanned = rEr * rRho\n + rEt * (pos.z - ${RIBBON_Z_CENTER.toFixed(1)}) * 0.5\n // Cone: lift the fan out of its own plane as it spreads, so the flat plume becomes a\n // TRUMPET whose combed strands run down the slant into the throat. 0 is the flat fan.\n + vec3(0.0, 0.0, pos.y + uv.y * 400.0 * uRadialCone);\n pos = mix(pos, fanned, clamp(uRadialAmount, 0.0, 1.0));\n }\n#endif\n\n#ifdef PATH\n // PATH — sweep the ribbon along an authored centreline. Everything above deformed a ribbon whose\n // centreline was the x-axis on the plane z = RIBBON_Z_CENTER; this carries that deformed\n // cross-section onto a curve instead. It is the LAST stage, so it bends whatever the ribbon has\n // already become — twists, helix, radial fan and all.\n //\n // A STRAIGHT path along the ribbon's own centreline is exactly the identity, which is what lets\n // the studio give a wave a path the moment it is double-clicked without the wave moving. Four\n // details hold that up, and each of them once broke it: the frame is right-handed (binormal +Z on\n // a straight path — a mirrored one flips the handedness of every twist); the LUT is read at\n // texel CENTRES and interpolated in-shader (reading it at s stretched the ribbon ~0.8%\n // about its middle); an open path is\n // EXTRAPOLATED past its ends along the end tangent rather than clamped (twists push vertices past\n // ±200, and clamping collapsed them onto the last frame); and a closed one wraps.\n //\n // Position, frame and width come from a small LUT the CPU baked (see wavePath.ts) — the frame is\n // parallel-transported, which cannot be done per vertex — and the two frame vectors are\n // re-normalized because a linear interpolation between unit vectors is not one.\n {\n float pathSRaw = (pos.x + 200.0) / 400.0;\n // Closure is in the binormal row's alpha; it decides how s is addressed, so read it first.\n // texture2D, not texture2DLod: three rewrites GLSL1 to GLSL3 on WebGL2 by replacing the\n // plain texture2D token, and the Lod spelling survives that rewrite as an undefined function.\n bool pathClosed = texture2D(uPathTex, vec2(${(0.5 / PATH_SAMPLES).toFixed(8)}, ${(2.5 / PATH_ROWS).toFixed(7)})).w > 0.5;\n float pathS = pathClosed ? fract(pathSRaw) : clamp(pathSRaw, 0.0, 1.0);\n // Interpolate between the two neighbouring samples HERE, at full precision, reading each at its\n // texel centre from a NEAREST texture. Hardware filtering of float32 is neither guaranteed (it\n // needs an extension) nor exact (its weights may be quantized) — see bakePathTexture.\n float pathI = pathS * ${(PATH_SAMPLES - 1).toFixed(1)};\n float pathI0 = floor(pathI);\n float pathF = pathI - pathI0;\n float pathU0 = (pathI0 + 0.5) / ${PATH_SAMPLES.toFixed(1)};\n float pathU1 = (min(pathI0 + 1.0, ${(PATH_SAMPLES - 1).toFixed(1)}) + 0.5) / ${PATH_SAMPLES.toFixed(1)};\n vec4 pP = mix(texture2D(uPathTex, vec2(pathU0, ${(0.5 / PATH_ROWS).toFixed(7)})),\n texture2D(uPathTex, vec2(pathU1, ${(0.5 / PATH_ROWS).toFixed(7)})), pathF);\n vec4 pNL = mix(texture2D(uPathTex, vec2(pathU0, ${(1.5 / PATH_ROWS).toFixed(7)})),\n texture2D(uPathTex, vec2(pathU1, ${(1.5 / PATH_ROWS).toFixed(7)})), pathF); // .w = arc length\n vec3 pN = normalize(pNL.xyz);\n vec3 pB = normalize(mix(texture2D(uPathTex, vec2(pathU0, ${(2.5 / PATH_ROWS).toFixed(7)})),\n texture2D(uPathTex, vec2(pathU1, ${(2.5 / PATH_ROWS).toFixed(7)})), pathF).xyz);\n float pathPast = pathClosed ? 0.0 : (pathSRaw - pathS) * pNL.w;\n pos = pP.xyz + cross(pN, pB) * pathPast + pN * pos.y\n + pB * ((pos.z - ${RIBBON_Z_CENTER.toFixed(1)}) * pP.w);\n }\n#endif\n\n WaveShape s;\n s.pos = pos;\n s.rotA = rotA;\n s.rotB = rotB;\n s.rotC = rotC;\n return s;\n}\n`;\n\n// ---------------------------------------------------------------------------------------------\n// DISSOLVE — the disintegration front. A band sweeps across the ribbon in uv and everything behind\n// it is eaten away, chunk by chunk, so the surface crumbles instead of fading. Shared verbatim by\n// the solid fragment shader, the wireframe fragment shader and the particle emitter (which reads\n// the same front to decide when each mote peels off), so the dust leaves exactly where the surface\n// goes. Everything sits behind `#ifdef DISSOLVE`, so a wave without one compiles the program it\n// always did.\n//\n// `front` is placed so that amount 0 leaves the whole ribbon and amount 1 takes all of it,\n// whatever the band width: the band starts entirely before the ribbon and ends entirely past it.\n// ---------------------------------------------------------------------------------------------\nconst dissolveChunk = /* glsl */ `\nuniform float uDissolveAmount; // 0..1 — how far the front has swept\nuniform float uDissolveBand; // width of the crumbling band, in uv\nuniform float uDissolveScale; // chunks across the ribbon's width\nuniform float uDissolveBlocky; // 0 = organic noise blobs, 1 = hard quantized cells\nuniform float uDissolveAxis; // 0 length (uv.y) · 1 width (uv.x) · 2 screen X · 3 screen Y\nuniform float uDissolveReverse; // 1 = sweep from the far end instead\n\n// The sweep coordinate, 0 where the front starts and 1 where it ends. Two families:\n// - the RIBBON's own axes (uv), so the front follows the sheet wherever the twist takes it;\n// - SCREEN space (ndc, 0..1 across the frame), so the front is a straight line on the canvas and\n// every wave in a stack disintegrates against the SAME edge no matter how each one is oriented.\n// The crumb pattern always stays in uv, so the chunks belong to the surface either way.\nfloat dissolveCoord(vec2 uv, vec2 ndc){\n float c = uDissolveAxis < 0.5 ? uv.y\n : uDissolveAxis < 1.5 ? uv.x\n : uDissolveAxis < 2.5 ? ndc.x\n : ndc.y;\n return uDissolveReverse > 0.5 ? 1.0 - c : c;\n}\n\n// How far the front has passed a point: 0 ahead of it (intact), 1 fully behind it (gone).\nfloat dissolveProgress(float coord){\n float band = max(uDissolveBand, 1.0e-3);\n float front = uDissolveAmount * (1.0 + band); // 0 -> band sits entirely before the ribbon\n return clamp((front - coord) / band, 0.0, 1.0);\n}\n\n// Per-chunk hash, cheap and stable: the same cell always returns the same value, so a chunk that\n// has crumbled stays crumbled as the front advances (it never flickers back).\nfloat dissolveHash(vec2 cell){\n return fract(sin(dot(floor(cell), vec2(127.1, 311.7))) * 43758.5453);\n}\n\n// The erosion grain at a uv: 0 = the first thing to go, 1 = the last. Two octaves (coarse chunks\n// with finer grit inside them) blended between smooth simplex (organic tatters) and quantized\n// cells (hard blocky debris) by uDissolveBlocky. Cells are made square ON THE RIBBON — the sheet\n// is 400 long by ~188 wide, so uv.y is stretched by that ratio.\nfloat dissolveGrain(vec2 uv){\n vec2 cell = vec2(uv.x, uv.y * 2.13) * uDissolveScale;\n float coarse = mix(simplexNoise(cell) * 0.5 + 0.5, dissolveHash(cell), uDissolveBlocky);\n float fine = mix(simplexNoise(cell * 3.7) * 0.5 + 0.5, dissolveHash(cell * 3.7), uDissolveBlocky);\n return clamp(coarse * 0.72 + fine * 0.28, 0.0, 1.0);\n}\n\n// True where the surface has been eaten away. ndc is this fragment's 0..1 screen position (from\n// vClipPosition), read only by the screen-space axes.\nbool dissolved(vec2 uv, vec2 ndc){\n return dissolveProgress(dissolveCoord(uv, ndc)) > dissolveGrain(uv);\n}\n`;\n\n// The POINTER FIELD, shared by the wave vertex shader and the particle emitter (particleVertexShader)\n// exactly as waveShapeChunk shares the deform — so a wave's dust reacts to the cursor through the SAME\n// footprint, falloff and displacement its ribbon does instead of staying pinned to the un-poked\n// surface. The whole chunk is interpolated INSIDE `#ifdef POINTER_FX` in both callers, so a wave with\n// no interaction config compiles the exact same program as before (JS-side uniform entries are always\n// present — see makeUniforms — but three only uploads uniforms the compiled program declares).\n// Requires simplexNoise and the uDispFreqX / uDispFreqZ shape uniforms declared above.\nconst pointerFieldChunk = /* glsl */ `\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;\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\n// fall = screen falloff × presence (the wave's vPointerFall, which both fragment themes consume);\n// disp = the signed displacement along the surface's own up-axis, which the CALLER applies (the wave\n// in its local space, the dust through the wave's world matrix).\nstruct PointerHit { float fall; float disp; };\n\n// Sample the field for ONE point. ndc is that point's screen position; mvp the clip transform of the\n// space rotA/rotB/rotC and churnPos live in (the owning wave's local space); t / loopOff the caller's\n// linear / orbit time — only the one selected by LOOP_MOTION is read.\nPointerHit pointerField(vec2 ndc, mat4 mvp, mat4 rotA, mat4 rotB, mat4 rotC, vec3 churnPos,\n float t, vec2 loopOff){\n // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.\n vec2 dp = (ndc - 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)) * uPointerActive;\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 * fall\n * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0, churnPos.z * uDispFreqZ * 3.0) + loopOff * 4.0);\n#else\n float disp = uPointerAgitate * fall\n * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0 + t * 4.0, churnPos.z * uDispFreqZ * 3.0));\n#endif\n // Membrane push/pull: a smooth dome (fall is the falloff) that swells toward you (+ repel) or dents\n // away (− attract) at the cursor, riding along with the sprung field.\n disp += uPointerPush * fall;\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 * fall * 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((ndc - 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 PointerHit hit;\n hit.fall = fall;\n hit.disp = disp;\n return hit;\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\n// Path (optional): the baked centreline LUT — row 0 position + width, row 1 normal, row 2 binormal.\n#ifdef PATH\nuniform sampler2D uPathTex;\n#endif\n\n// Radial fan (optional). Behind RADIAL so a wave without one compiles the exact same program (same\n// byte-identity contract as HELIX / POINTER_FX above).\n#ifdef RADIAL\nuniform float uRadialAmount; // 0..1 blend (0 = identity)\nuniform float uRadialArc; // fan spread, degrees\nuniform float uRadialSpread; // length → radius scale\nuniform float uRadialRadius; // source / inner radius\nuniform float uRadialCenter; // base angle, degrees\nuniform float uRadialCone; // lift per unit radius: 0 = a flat fan, >0 = a cone / trumpet\nuniform float uRadialSwirl; // degrees of angle gained over the band's length: 0 = straight arms\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// Vertex normal (optional): the deformed surface's normal from finite differences of the SAME\n// deformation at two baked neighbours — exact for whatever the shape does, and smooth across the\n// mesh where the fragment's dFdx normal is constant per triangle. Glass reads it; behind\n// VERTEX_NORMAL so the other themes compile the exact same program.\n#ifdef VERTEX_NORMAL\nattribute vec4 positionU; // next vertex across the width: xyz = base position, w = signed uv.x step\nattribute vec4 positionV; // next vertex along the length, likewise (w = signed uv.y step)\nvarying vec3 vNormal; // world space, unit length; zero where the surface is degenerate\n#endif\n\n// Pointer field (optional, additive) — the shared chunk, gated so a wave with no interaction config\n// compiles the exact same program. The particle emitter interpolates the SAME chunk, so dust reacts\n// through one implementation of the footprint / falloff / displacement.\n#ifdef POINTER_FX\n${pointerFieldChunk}\nvarying float vPointerFall; // falloff × presence — consumed by both fragment themes\n#endif\n\n${waveShapeChunk}\n\nvoid main(){\n vUv = uv;\n#ifndef LOOP_MOTION\n float t = uTime * uSpeed + uSeed;\n vec2 loopOff = vec2(0.0); // unused under linear time; kept so waveShape's signature is uniform\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 float t = 0.0; // unused under loop time\n#endif\n\n // Deform the baked hairpin via the shared waveShape chunk (displacement + helix + twist + radial),\n // which also drives the particle shed emitter. It returns the deformed local pos + the twist\n // matrices the pointer field reads below.\n WaveShape ws = waveShape(position, uv, t, loopOff);\n vec3 pos = ws.pos;\n#ifdef VERTEX_NORMAL\n // The two neighbours through the SAME deformation (and the same pointer bump, below).\n WaveShape wsU = waveShape(positionU.xyz, uv + vec2(positionU.w, 0.0), t, loopOff);\n WaveShape wsV = waveShape(positionV.xyz, uv + vec2(0.0, positionV.w), t, loopOff);\n vec3 posU = wsU.pos;\n vec3 posV = wsV.pos;\n#endif\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. The field itself lives in pointerFieldChunk, which the\n // particle emitter also calls — so dust reacts through this exact footprint and falloff.\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 PointerHit hit = pointerField(preClip.xy / max(preClip.w, 1.0e-6), mvp,\n ws.rotA, ws.rotB, ws.rotC, pos, t, loopOff);\n vPointerFall = hit.fall;\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) * ws.rotA) * ws.rotB) * ws.rotC).xyz;\n pos += dispAxis * hit.disp;\n#ifdef VERTEX_NORMAL\n // The neighbours ride the same bump, each from its own clip position and its own twist frame, so\n // the normal follows the pointer's displacement rather than ignoring it.\n vec4 clipU = mvp * vec4(posU, 1.0);\n PointerHit hitU = pointerField(clipU.xy / max(clipU.w, 1.0e-6), mvp,\n wsU.rotA, wsU.rotB, wsU.rotC, posU, t, loopOff);\n posU += (((vec4(0.0, 1.0, 0.0, 0.0) * wsU.rotA) * wsU.rotB) * wsU.rotC).xyz * hitU.disp;\n vec4 clipV = mvp * vec4(posV, 1.0);\n PointerHit hitV = pointerField(clipV.xy / max(clipV.w, 1.0e-6), mvp,\n wsV.rotA, wsV.rotB, wsV.rotC, posV, t, loopOff);\n posV += (((vec4(0.0, 1.0, 0.0, 0.0) * wsV.rotA) * wsV.rotB) * wsV.rotC).xyz * hitV.disp;\n#endif\n#endif\n\n#ifdef VERTEX_NORMAL\n {\n // Tangents transform covariantly, so mat3(modelMatrix) is right for any scale — a normal would\n // need its inverse transpose. sign(w) undoes the backward step the last row and column take.\n vec3 tU = mat3(modelMatrix) * ((posU - pos) * sign(positionU.w));\n vec3 tV = mat3(modelMatrix) * ((posV - pos) * sign(positionV.w));\n vec3 n = cross(tU, tV);\n vNormal = n / max(length(n), 1.0e-9);\n }\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; // softness of the ribbon's two ENDS (only when it differs from 0.1)\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#ifdef DISSOLVE\n${dissolveChunk}\n#endif\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 folded WIDTH — see WaveGeometry's UV AXES note),\n // packing many thin stripes across the cross-section while uv.y is barely scaled, so each\n // one stretches out into a fine LENGTHWISE fiber. 1 - parabola(uv.x) then weights them\n // toward the two long edges and away from the width centreline.\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#ifdef DISSOLVE\n // The disintegration front: drop the chunks it has already eaten, before any shading work.\n if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;\n#endif\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 ribbon ENDS (it fades on vUv.y, the length) + optional viewport-edge fade. The edge\n // 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// ---- Glass theme: a refracting sheet ----\n// The ribbon stops being a coloured surface and becomes a LENS over whatever is behind it. The\n// backdrop (everything drawn before this wave) arrives as a texture and is sampled at an offset\n// that follows the surface's own normal, so the bend is strongest where the sheet turns away from\n// the camera and vanishes where it faces us — which is what compresses an edge and reads as\n// thickness. Three ingredients carry the look, all borrowed from 2D \"liquid glass\" work and\n// re-derived against a real normal instead of a baked rounded-rect map:\n//\n// · dispersion — the three channels sample at slightly different offsets, so edges fringe.\n// · adaptive specular — the glint ADDS over a dark backdrop and DARKENS over a bright one. A\n// purely additive highlight disappears on white paper, which is most of this library's output.\n// · vibrancy — a pull toward mid-grey inside the sheet, the haze that separates glass from a hole.\n//\n// Nothing here is time-varying: the LIQUID comes from the geometry, which is already moving, so the\n// refraction flows with the wave for free.\nexport const glassFragmentShader = /* glsl */ `\n#define MAX_COLORS ${MAX_COLORS}\n#define MAX_MESH_POINTS ${MAX_MESH_POINTS}\n#define MAX_LIGHTS ${MAX_LIGHTS}\n#define PI 3.14159265359\n\n${simplex2d}\n\n${colorUniforms}\nuniform sampler2D uBackdrop; // everything drawn behind this wave, in screen space\nuniform vec2 uResolution;\nuniform float uGlassStrength; // peak bend at the silhouette, in pixels\nuniform float uGlassChroma;\nuniform float uGlassFrost;\nuniform float uGlassSpec;\nuniform float uGlassVibrancy;\nuniform float uGlassTint;\nuniform float uGlassRimPower;\nuniform vec3 uClearColor; // the page behind a transparent scene\nuniform float uGlassPath; // HALF the optical path at normal incidence\nuniform float uGlassDensity; // absorption coefficient\nuniform float uGlassRim;\nuniform float uGlassIrid;\nuniform float uGlassFilmNm;\nuniform float uGlassIor;\nuniform sampler2D uLayers; // glass layers covering this pixel, 1/8 each\nuniform float uGlassLayerGain;\nuniform float uGlassFusion; // droplet merge: bend along the MERGED silhouette, not each normal\nuniform float uGlassCaustic;\nuniform vec3 uViewAxis; // world-space axis from the surface TOWARD an orthographic camera\nuniform float uGlassRipple; // liquid: how hard the travelling waves tilt the normal\nuniform float uGlassRippleScale; // waves per world unit\nuniform float uGlassFlow; // rad/s\nuniform float uTime;\nuniform float uAmbient;\nuniform int uNumLights;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightIntensity[MAX_LIGHTS];\nuniform float uEdgeFeather;\nuniform float uEdgeFade;\n\nvarying vec2 vUv;\nvarying vec3 vWorldPos;\nvarying vec3 vViewDir;\nvarying vec4 vClipPosition;\n#ifdef VERTEX_NORMAL\nvarying vec3 vNormal;\n#endif\n\n#ifdef DISSOLVE\n${dissolveChunk}\n#endif\n\n${colorFns}\n\n// LIQUID: four travelling trig waves added to the normal as a gradient. Trig rather than scrolled\n// noise on purpose — a scrolled texture drifts one way and reads as a conveyor belt, where crossing\n// waves interfere, which is what water does. The temporal frequencies are integer multiples of one\n// phase so a loop that closes for the motion closes for the water, and the four spatial vectors are\n// incommensurate so the pattern does not visibly repeat.\nvec3 rippleNormal(vec3 N, vec3 p){\n float ph = uTime * uGlassFlow;\n vec3 k1 = vec3( 1.00, 0.62, 0.31);\n vec3 k2 = vec3(-0.54, 1.13, 0.47);\n vec3 k3 = vec3( 0.36, -0.82, 1.07);\n vec3 k4 = vec3(-1.18, -0.33, 0.72);\n vec3 g = vec3(0.0);\n g += k1 * cos(dot(p, k1) * uGlassRippleScale + ph);\n g += k2 * cos(dot(p, k2) * uGlassRippleScale - ph * 2.0 + 1.7) * 0.65;\n g += k3 * cos(dot(p, k3) * uGlassRippleScale + ph * 3.0 + 3.9) * 0.42;\n g += k4 * cos(dot(p, k4) * uGlassRippleScale - ph + 2.6) * 0.55;\n return normalize(N + g * uGlassRipple * 0.16);\n}\n\n// Thin-film interference, tinting only what BOUNCES — reflection, rim and specular. Colouring the\n// transmission too reads as dye rather than as a film on the surface.\nvec3 thinFilm(float ndv){\n float s2 = (1.0 - ndv * ndv) / max(uGlassIor * uGlassIor, 1.0e-4);\n float cosT = sqrt(max(1.0 - s2, 0.0));\n vec3 phase = 6.2831853 * (2.0 * uGlassIor * uGlassFilmNm * cosT) / vec3(650.0, 550.0, 440.0);\n return mix(vec3(1.0), 0.5 + 0.5 * cos(phase), clamp(uGlassIrid, 0.0, 1.0));\n}\n\n// One frosted tap set, taken AT the already-refracted position so the blur rides the bend instead\n// of sitting flat underneath it. Five taps is enough at these radii; more just costs fill.\n// The backdrop is captured OPAQUE, cleared to the page colour, so a sample is simply the colour\n// behind the glass. It used to be captured transparent and composited over the page here, which\n// left the result at the mercy of what alpha a render target hands back — and the two backends\n// disagree about that.\nvec3 backdropAt(vec2 uv){\n return texture2D(uBackdrop, uv).rgb;\n}\n\n// Droplet fusion. Two sheets passing close should behave like one blob of something viscous rather\n// than two objects overlapping — and the trick that sells it is not the shape but the DIRECTION of\n// the bend: in the neck between them the surface normal has to rotate smoothly from one rim to the\n// other, or the refraction tears between two centres.\n//\n// The 2D original merges signed-distance fields with a smooth minimum and takes the direction from\n// the gradient of the merged field. There is no SDF here, but the layer-coverage buffer is the same\n// thing in screen space once it is smeared: blur it and two nearby silhouettes bridge, exactly as a\n// smooth minimum bridges two distance fields. Its gradient is then the merged normal, for free.\nfloat coverageField(vec2 uv){\n vec2 r = 9.0 / uResolution;\n float f = texture2D(uLayers, uv).r * 4.0;\n f += texture2D(uLayers, uv + vec2(r.x, 0.0)).r * 2.0;\n f += texture2D(uLayers, uv - vec2(r.x, 0.0)).r * 2.0;\n f += texture2D(uLayers, uv + vec2(0.0, r.y)).r * 2.0;\n f += texture2D(uLayers, uv - vec2(0.0, r.y)).r * 2.0;\n f += texture2D(uLayers, uv + r).r;\n f += texture2D(uLayers, uv - r).r;\n f += texture2D(uLayers, uv + vec2(r.x, -r.y)).r;\n f += texture2D(uLayers, uv + vec2(-r.x, r.y)).r;\n return f / 16.0;\n}\n\n// Frosting is SCATTER, not blur. Blurring one lookup smears whatever that single ray happened to\n// hit, which reads as a dirty window; spreading real samples over a cone is what loses the image\n// behind while keeping the light. Eleven samples on a golden-angle spiral, spread by sqrt(i/N) so\n// they cover the disc evenly, rotated per PIXEL (hashed from the coordinate, not from time — a\n// time-varying rotation boils) so the pattern does not tile.\n#define FROST_SAMPLES 11\nfloat frostRotation(vec2 p){\n return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453) * 6.2831853;\n}\nvec3 frostSample(vec2 uv, float radiusPx){\n float rot = frostRotation(gl_FragCoord.xy);\n vec2 r = radiusPx / uResolution;\n vec3 acc = vec3(0.0);\n for (int i = 0; i < FROST_SAMPLES; i++) {\n float t = (float(i) + 0.5) / float(FROST_SAMPLES);\n float a = rot + float(i) * 2.399963; // golden angle\n acc += backdropAt(uv + vec2(cos(a), sin(a)) * r * sqrt(t));\n }\n return acc / float(FROST_SAMPLES);\n}\n\n// The shading normal for a raw — interpolated, unnormalised — surface normal: unit length, faced\n// toward the viewer (the orientation the screen-derivative normal always had, so a thin sheet\n// bends the same way whichever face is in front), then rippled. A zero-length input faces the\n// camera and bends nothing. Shared by the shading path and the caustic's finite differences, so\n// the two can never disagree about the surface.\nvec3 glassShadingNormal(vec3 rawN, vec3 pos, vec3 V, out vec3 flatN){\n float nLen = length(rawN);\n vec3 N = nLen > 1.0e-6 ? rawN / nLen : V;\n if (dot(N, V) < 0.0) N = -N;\n flatN = N;\n if (uGlassRipple > 0.001) N = rippleNormal(N, pos);\n return N;\n}\n\n// The refraction offset, in pixels, of the surface at (rawN, pos) — before droplet fusion.\nvec2 glassOffset(vec3 rawN, vec3 pos, vec3 V){\n vec3 flatN;\n vec3 N = glassShadingNormal(rawN, pos, V, flatN);\n float rim = pow(1.0 - abs(dot(N, V)), max(uGlassRimPower, 0.001));\n // The ripple is fed into the OFFSET as well as the normal — see main().\n vec2 dir = -(N.xy + (N.xy - flatN.xy) * 2.0);\n return dir * mix(rim, 1.0, uGlassRipple * 0.25) * uGlassStrength;\n}\n\nvoid main(){\n#ifdef DISSOLVE\n if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;\n#endif\n // ORTHOGRAPHIC camera: every ray is parallel, so the view direction is the camera's forward axis,\n // NOT a per-fragment vector to the eye. vViewDir (cameraPosition - world) is the perspective form\n // and under ortho it fans out across the frame — using it swings the rim band and the specular\n // across the ribbon as if the camera were inches away. Fed as a uniform rather than dug out of\n // viewMatrix, because the TSL twin cannot index a matrix node and the two must not diverge.\n vec3 V = normalize(uViewAxis);\n#ifdef VERTEX_NORMAL\n // The interpolated vertex normal: smooth across the mesh, where dFdx of the world position is\n // constant per triangle and a 120 px refraction turned every triangle edge into a seam.\n vec3 rawN = vNormal;\n#else\n vec3 rawN = cross(dFdx(vWorldPos), dFdy(vWorldPos)); // per triangle: the caustic sees no curvature\n#endif\n vec3 flatN; // the geometric normal, kept so the ripple's CONTRIBUTION can be isolated below\n vec3 N = glassShadingNormal(rawN, vWorldPos, V, flatN);\n // The rim band, in 3D: 1 where the surface grazes the eye, 0 where it faces us. This is the same\n // curve the 2D work bakes as a rounded-rect inset, except it comes from the geometry, so it\n // follows every fold and twist without anything being authored.\n float rim = pow(1.0 - abs(dot(N, V)), max(uGlassRimPower, 0.001));\n\n vec2 sUv = gl_FragCoord.xy / max(uResolution, vec2(1.0));\n // The ripple is fed into the OFFSET as well as the normal. Tilting a normal where the surface\n // faces the camera barely changes N·V, so on a broad flat ribbon the ripple was nearly invisible\n // and only showed on the twisting flanks; displacing there costs nothing and reads everywhere.\n vec2 dir = -(N.xy + (N.xy - flatN.xy) * 2.0);\n if (uGlassFusion > 0.001) {\n // Gradient of the smeared coverage, by finite difference. Pointing INTO the merged shape is the\n // same convention the geometric normal uses, so the two blend without flipping the bend.\n vec2 g = 9.0 / uResolution;\n vec2 grad = vec2(\n coverageField(sUv + vec2(g.x, 0.0)) - coverageField(sUv - vec2(g.x, 0.0)),\n coverageField(sUv + vec2(0.0, g.y)) - coverageField(sUv - vec2(0.0, g.y))\n );\n if (dot(grad, grad) > 1.0e-8) dir = mix(dir, normalize(grad), clamp(uGlassFusion, 0.0, 1.0));\n }\n vec2 offPx = dir * mix(rim, 1.0, uGlassRipple * 0.25) * uGlassStrength;\n vec2 off = offPx / max(uResolution, vec2(1.0));\n\n // Dispersion: the same bend at three slightly different scales, one per channel.\n vec2 uvR = sUv + off * (1.0 + uGlassChroma * 0.2);\n vec2 uvG = sUv + off * (1.0 + uGlassChroma * 0.1);\n vec2 uvB = sUv + off;\n vec3 col = vec3(backdropAt(uvR).r, backdropAt(uvG).g, backdropAt(uvB).b);\n // CAUSTICS. A caustic is not a decal painted near the glass — it is what happens when the\n // refraction map compresses, so neighbouring rays land on top of each other and energy piles up.\n // The sampling map here is m(p) = p + offPx(p), so its Jacobian is the identity plus the offset's\n // screen-space derivative, and brightness goes as 1/|det J|: below 1 where the map compresses,\n // above 1 where it spreads. The derivatives are already free in a fragment shader, which is why\n // this needs no extra pass — it is the gather form of the usual light-space splat.\n if (uGlassCaustic > 0.001) {\n // One-pixel forward differences of the offset, re-evaluated at the neighbouring pixel's normal\n // and position. Those come from dFdx of the INTERPOLATED normal (and of the position, for the\n // ripple), which is linear across a triangle, so every backend and derivative mode agrees on\n // it to the bit — where dFdx of the offset itself, a nonlinear function of the normal, is\n // implementation-defined (coarse or fine) and split the two backends at every steep fold.\n // It works at all because the normal is interpolated: derived from dFdx of the position it was\n // constant per quad, its own derivative identically zero, and the caustic needed a separate\n // normal buffer and a 3 px stencil to get a second difference out of a first-difference normal.\n // A ±3 px central difference — the baseline the normal-buffer stencil had. The map's fold is a\n // pole in 1/|det J|, and a one-pixel difference lands so close to it that rounding alone moved\n // the bright band between the two backends; six pixels of baseline keep them on the same side.\n vec3 dNx = dFdx(rawN) * 3.0;\n vec3 dNy = dFdy(rawN) * 3.0;\n vec3 dPx = dFdx(vWorldPos) * 3.0;\n vec3 dPy = dFdy(vWorldPos) * 3.0;\n vec2 dOdx = (glassOffset(rawN + dNx, vWorldPos + dPx, V)\n - glassOffset(rawN - dNx, vWorldPos - dPx, V)) / 6.0;\n vec2 dOdy = (glassOffset(rawN + dNy, vWorldPos + dPy, V)\n - glassOffset(rawN - dNy, vWorldPos - dPy, V)) / 6.0;\n float detJ = (1.0 + dOdx.x) * (1.0 + dOdy.y) - dOdy.x * dOdx.y;\n // The floor matters: at a fold the map folds too, det passes through zero, and the true\n // brightness there is infinite. Real caustics are bounded by the width of the light source, so\n // clamping is physical rather than a fudge — it is what stops a cusp blowing out to white.\n float gain = clamp(1.0 / max(abs(detJ), 0.12), 0.0, 6.0);\n col *= mix(1.0, gain, clamp(uGlassCaustic, 0.0, 1.0));\n }\n\n // Radius grows with the SQUARE of frost, the way a scattering lobe does: gentle at the low end\n // where you want a hint of ground glass, and genuinely opaque by the top. Gated on the radius in\n // PIXELS, not on the knob: under half a pixel the eleven taps average back to the bilinear\n // sample they surround, so the default 0.08 (0.29 px) was paying for a blur nobody could see.\n float frostRadius = uGlassFrost * uGlassFrost * 46.0;\n if (frostRadius > 0.5) {\n // One RGB gather at the green offset. Gathering per channel tripled the taps for a dispersion\n // the scatter itself washes out at any radius where the frost is visible at all.\n col = mix(col, frostSample(uvG, frostRadius), clamp(uGlassFrost, 0.0, 1.0));\n }\n\n // ---- the material itself ----\n // This is what makes glass a MATERIAL and not a window. The ribbon's own palette is treated as\n // transmitted light, absorbed over the sheet's own thickness: 2·path at normal incidence, longer\n // as the surface turns away. A single-sided ribbon has no back face to measure against, so the\n // chord is analytic. The result survives with nothing behind it — the page is simply what the\n // colour is absorbed OUT of.\n float ndv = clamp(abs(dot(N, V)), 0.02, 1.0);\n vec3 lit = applyColorGrade(waveBaseColor(vUv));\n // Thickness. The analytic term is the chord through one sheet; the layer count adds the folds\n // stacked behind this fragment, which opaque drawing would otherwise throw away.\n float layers = max(texture2D(uLayers, sUv).r * 8.0, 1.0);\n float chord = 2.0 * uGlassPath * pow(ndv, 0.40) * (1.0 + uGlassLayerGain * (layers - 1.0));\n float trans = 1.0 - exp(-uGlassDensity * chord);\n // True per-channel Beer-Lambert. The palette is read as what the sheet LETS THROUGH, so its dark\n // channels absorb and its bright ones pass: pink glass over cream paper stays pink instead of\n // washing to cream. The alternative — normalising to the brightest channel and tinting — can only\n // ever lighten, so deep glass came out as a pale film however far its thickness was pushed.\n // The palette sets the HUE of what gets through; density sets how much is stopped. Every channel\n // absorbs something (the 0.9 keeps the floor above zero), so thickness DARKENS as well as tints —\n // which is the part that reads as a solid volume. Deriving absorption straight from the palette\n // instead fails on this library's bright palettes: 1-lit is then near zero, nothing is absorbed,\n // and thick glass comes out as pale as thin.\n vec3 hue = lit / max(max(lit.r, max(lit.g, lit.b)), 0.001);\n vec3 sigma = uGlassDensity * (1.0 - hue * 0.9);\n // Dispersion in the BODY, not only in the backdrop lens: each channel travels a slightly\n // different path, so a thick edge fringes even with nothing behind the sheet to bend. Without\n // this, glassChroma did nothing at all on a standalone wave.\n vec3 chordRGB = chord * (1.0 + vec3(uGlassChroma * 0.12, 0.0, -uGlassChroma * 0.12));\n vec3 transmittance = exp(-sigma * chordRGB);\n col = col * mix(vec3(1.0), transmittance, clamp(uGlassTint, 0.0, 1.0));\n\n vec3 film = thinFilm(ndv);\n\n // Fresnel: at a grazing angle the sheet stops transmitting and starts mirroring. With nothing to\n // mirror it reflects the page, which is exactly what glass on paper does.\n float f0 = pow((uGlassIor - 1.0) / (uGlassIor + 1.0), 2.0);\n float F = f0 + (1.0 - f0) * pow(1.0 - ndv, 5.0);\n // The reflection weight is deliberately LOW. Over a dark room the bounce is the only thing\n // describing the solid and wants to dominate; over bright paper the same weight turns the whole\n // sheet white and the colour we just absorbed is thrown away.\n col = mix(col, mix(uClearColor, vec3(1.0), 0.35) * film, F * (0.18 + uGlassIrid * 0.4));\n\n // Rim. The window is WIDE on purpose: a band that only covers the last few degrees before\n // edge-on is thinner than a pixel on a ribbon, and a knob nothing responds to is not subtle, it\n // is broken. A narrow darker band just inside gives the edge a lip rather than a glow.\n col = mix(col, film, smoothstep(mix(0.62, 0.42, uGlassIrid), 1.0, 1.0 - ndv) * uGlassRim);\n col *= 1.0 - smoothstep(0.62, 0.86, 1.0 - ndv) * 0.10;\n\n // TWO keys, and a wide lobe. One overhead light never reaches a surface whose normals are all\n // horizontal — a twisted ribbon has plenty of those — and no exponent fixes that, so a second,\n // low key near the view axis fills them in.\n vec3 KEY = normalize(vec3(-0.30, 0.86, 0.42));\n vec3 KEY_FILL = normalize(vec3(0.42, 0.16, 0.89));\n vec3 mirror = reflect(-V, N);\n float lobe = pow(max(dot(mirror, KEY), 0.0), 40.0)\n + 0.55 * pow(max(dot(mirror, KEY_FILL), 0.0), 40.0);\n float spec = (lobe + rim * 0.25) * uGlassSpec;\n col += lobe * uGlassSpec * 0.35 * film;\n\n float lumaV = dot(col, vec3(0.299, 0.587, 0.114));\n // Over a dark backdrop the glint adds; over a bright one it darkens. Without this the rim simply\n // disappears on the warm paper most of these scenes use.\n float darkBlend = smoothstep(0.25, 0.7, lumaV);\n col = max(mix(col + spec, col * (1.0 - spec), darkBlend), 0.0);\n // Vibrancy: pull the interior toward mid-grey — the haze that says \"glass\" rather than \"hole\".\n col += (0.5 - lumaV) * uGlassVibrancy;\n\n // Glass draws OPAQUE, so alpha never reaches a blend: a soft edge has to fade toward the UNBENT\n // backdrop instead. Scaling the colour by alpha, as the blended themes do under\n // PREMULTIPLIED_ALPHA, faded the feather toward black and drew a hard dark line along every\n // silhouette. Opacity folds into the same fade — for a sheet with nothing to blend against,\n // \"half opaque\" can only mean \"half as much bending\".\n float fade = clamp(uOpacity, 0.0, 1.0);\n if (uEdgeFeather > 0.0) {\n float e = min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y));\n fade *= smoothstep(0.0, uEdgeFeather, e);\n }\n col = mix(backdropAt(sUv), col, fade);\n gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);\n}\n`;\n\n// ---- Glass layer count ----\n// The companion program for the glass LAYER pass: the wave's own vertex stage, so every twist,\n// path and ripple lands exactly where the shaded frame puts it and both faces of the sheet count,\n// with a fragment that only says \"one layer is here\". Drawn additively with depth off into the\n// layer target, 1/8 per layer, so the channel saturates at eight folds — well past anything a\n// ribbon does to itself. A stock override material could not stand in: its vertex stage knows\n// nothing of the deformation and it culls back faces, so the count came out for the REST-POSE\n// plane (measured at a tenth of the real silhouette on the Liquid Glass preset).\nexport const glassLayerFragmentShader = /* glsl */ `\n#define MAX_COLORS ${MAX_COLORS}\n#define MAX_MESH_POINTS ${MAX_MESH_POINTS}\n#define MAX_LIGHTS ${MAX_LIGHTS}\n\n${simplex2d}\n\n${colorUniforms}\nuniform vec2 uResolution;\nuniform float uTime;\n\nvarying vec2 vUv;\nvarying vec4 vClipPosition;\n\n#ifdef DISSOLVE\n${dissolveChunk}\n#endif\n\nvoid main(){\n#ifdef DISSOLVE\n if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;\n#endif\n gl_FragColor = vec4(vec3(0.125), 1.0);\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// LENGTHWISE strands (abs(sin(uv.x * lineAmount)) — uv.x is the folded width, so lineAmount\n// counts strands ACROSS the cross-section and each runs end to end) whose thickness scales\n// with the screen-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 MAX_LIGHTS ${MAX_LIGHTS}\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 uLineDepthFade; // 1 = the original hardcoded recede, 0 = flat/graphic\n// Lighting (optional). The line theme is otherwise UNLIT: a strand's colour comes from its uv alone,\n// so it is the same tone wherever the surface turns, which is what makes a dense wireframe read as a\n// printed pattern rather than as an object. This shades it with the same derivative normal, lights\n// and crease the solid theme uses, so a single strand brightens and darkens ALONG its own length as\n// the ribbon curves — which is the whole difference between a drawing and a lit form.\n#ifdef LINE_LIGHT\nuniform float uLineLight; // 0 = flat (the theme as it was), 1 = fully shaded\n// Round section and glint are CONSTANTS, not knobs: shading a flat stripe barely reads, so lighting\n// and rounding only make sense together — one control, tuned once.\n#define LINE_ROUND 1.2\n#define LINE_GLINT 1.5\nuniform float uAmbient;\nuniform int uNumLights;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightIntensity[MAX_LIGHTS];\nvarying vec3 vWorldPos;\nvarying vec3 vViewDir;\n#endif\n#ifdef EDGE_FEATHER\nuniform float uEdgeFeather; // softness of the ribbon's two ENDS (shared with the solid theme)\n#endif\n#ifdef LINE_CLEAR_GAPS\nuniform float uLineGapOpacity; // how much page colour the gaps between strands carry (0 = clear)\n#endif\n#ifdef LINE_SHARP\nuniform float uLineSharpness; // 0..1 — steepen the stripe profile toward a hard duty cycle\n#endif\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\n#ifdef DISSOLVE\n${dissolveChunk}\n#endif\n\nvoid main(){\n#ifdef DISSOLVE\n // The disintegration front: drop the chunks it has already eaten (see dissolveChunk).\n if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;\n#endif\n // Same 2D palette sample + colour ops as the solid theme.\n vec3 color = applyColorGrade(waveBaseColor(vUv));\n\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 lengthwise strands; thickness from the screen-space uv derivative.\n vec2 dy = dFdy(vUv);\n // 1232 is a fixed reference width, not a knob: the old uMaxWidth uniform only ever multiplied the\n // derivative before the power, and pow(a*b, p) = pow(a, p)·pow(b, p) — so every value of it was\n // reachable by scaling lineThickness instead. See normalizeWave, which migrates the old field.\n float lineThickness = uLineThickness * pow(abs(dy.x * 1232.0), uLineDerivativePower);\n#ifdef POINTER_FX\n lineThickness *= clamp(1.0 - uPointerThin * vPointerFall, 0.0, 1.0); // wireframe: taper strands\n#endif\n // Each stripe family's per-pixel RATE — how fast its |sin| argument moves — and the DUTY CYCLE it\n // averages to, which is the fraction of a period that is strand. Both are needed below: once a\n // period is finer than a pixel, sampling |sin| at one arbitrary point per period is meaningless\n // (and is where two backends' derivative estimates diverge), so the coverage fades to the tone the\n // strands actually average to. That is also what a compressed region should look like: a solid\n // tone, not noise.\n float lineRate = uLineAmount * fwidth(vUv.x);\n#ifdef LINE_LIGHT\n {\n // The same derivative normal the solid theme uses — the mesh is finely subdivided, so it is\n // smooth enough to shade with. Flipped toward the camera because a ribbon is double-sided.\n vec3 N = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));\n vec3 Vd = normalize(vViewDir);\n if (dot(N, Vd) < 0.0) N = -N;\n // ROUND STRANDS. Until here a strand is a MASK — a stripe painted on a flat sheet, with no\n // cross-section of its own, which is why a dense wireframe reads as print however it is lit: a\n // printed line has no side to catch a highlight. Bending the normal ACROSS each stripe turns\n // every strand into a half-round filament, so the light runs along one and not its neighbour and\n // the bundle reads as combed thread rather than as hatching.\n //\n // The across-vector is the world direction of increasing uv.x, recovered from the screen-space\n // derivatives by least squares (the chain rule the other way round): it is the axis to tilt\n // about, and it is what makes the shading follow the strands wherever the surface turns.\n {\n vec2 gu = vec2(dFdx(vUv.x), dFdy(vUv.x));\n float gg = dot(gu, gu);\n if (gg > 1.0e-12) {\n vec3 across = (dFdx(vWorldPos) * gu.x + dFdy(vWorldPos) * gu.y) / gg;\n across = normalize(across - N * dot(across, N)); // keep it in the surface\n // Signed position across the strand: 0 at its crest, ±1 at its edges.\n float sAcross = clamp(sin(vUv.x * uLineAmount) / max(lineThickness, 1.0e-4), -1.0, 1.0);\n N = normalize(N + across * sAcross * LINE_ROUND);\n if (dot(N, Vd) < 0.0) N = -N;\n }\n }\n float facing = abs(dot(N, Vd));\n // Base shading: grazing parts of the surface fall away toward shadow, the facing body keeps its\n // colour. This alone is what makes a strand shade along its length.\n vec3 lit = color * mix(0.08, 1.0, facing);\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 lit += color * max(dot(N, L), 0.0) * lc * 0.5;\n // A tight specular, which on a combed surface is the glint that runs along one strand and not\n // its neighbour — the thing that reads as filament rather than as print.\n lit += pow(max(dot(N, normalize(L + Vd)), 0.0), 48.0) * lc * LINE_GLINT;\n }\n lit *= 0.55 + clamp(uAmbient, 0.0, 1.0);\n color = mix(color, lit, clamp(uLineLight, 0.0, 1.0));\n }\n#endif\n\n float a = abs(sin(vUv.x * uLineAmount));\n a = smoothstep(lineThickness, 0.0, a);\n // NOTE there is deliberately no duty-cycle fallback for the LENGTHWISE family, though the rungs\n // below have one. Its threshold is lineThickness, which is itself built from dFdy(vUv).x, so a\n // fallback keyed on it would add a SECOND derivative for the two backends to disagree about —\n // measured, it made cross-backend agreement worse, not better. The rungs' threshold is a plain\n // pixel width, which is why the same trick works there.\n float rungRate = 0.0; // the cross-wise family's rate / duty; 0 unless rungs are compiled in\n float dutyRung = 0.0;\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 rungRate = uRungAmount * fwidth(vUv.y);\n float rungT = uRungThickness * rungRate;\n a = max(a, smoothstep(rungT, 0.0, abs(sin(vUv.y * uRungAmount))));\n dutyRung = 0.63661977 * asin(clamp(rungT * 0.5, 0.0, 1.0));\n#endif\n\n#ifdef LINE_SHARP\n // Harden the stripe: the value above is a SOFT ramp — |sin| feathered over the whole half-period — so\n // raising uLineThickness widens the strands by fading the gaps out with them, and the surface goes\n // from pale hairlines straight to flat solid without ever passing through dense ink. Steepening it\n // about its own midpoint separates the two: uLineThickness becomes the DUTY CYCLE (where the ramp\n // crosses 0.5) and this becomes the edge, so a wave can be 70% ink with crisp gaps still showing.\n //\n // Applied to the MERGED coverage, after the rungs have been folded in, so a cross-wise family\n // reaches dense ink the same way a lengthwise one does. The floor is the ANALYTIC stripe rate\n // rather than fwidth() of that merged value, whose derivative is undefined where the two families\n // swap over.\n float aaRate = 0.5 * max(lineRate, rungRate);\n a = clamp((a - 0.5) / max(1.0 - uLineSharpness, aaRate * 1.4) + 0.5, 0.0, 1.0);\n#endif\n\n // Sub-pixel rungs: fade to the tone those strands average to (see the note above for why only\n // this family gets it).\n a = mix(a, max(a, dutyRung), smoothstep(1.2, 3.0, rungRate));\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) * uLineDepthFade;\n float cov = a * (1.0 - depthFade);\n // Soft ribbon ENDS, exactly as the solid theme fades them (on vUv.y, the length). Without this a\n // wireframe ribbon stops dead: its end-cap is a flat cross-section that reads as a straight cut\n // drawn across the strands, which is glaring the moment a ribbon curls back into frame. The 0.1\n // default matches the solid theme's hardcoded value, so a wave that never set edgeFeather keeps\n // its old ends — this only ever softens what was already an abrupt stop.\n#ifdef EDGE_FEATHER\n cov *= smoothstep(0.0, uEdgeFeather, vUv.y) * (1.0 - smoothstep(1.0 - uEdgeFeather, 1.0, vUv.y));\n#else\n cov *= smoothstep(0.0, 0.1, vUv.y) * (1.0 - smoothstep(0.9, 1.0, vUv.y));\n#endif\n#ifdef LINE_CLEAR_GAPS\n // CLEAR GAPS. By default the gaps between strands are painted with the page colour, which makes a\n // wireframe wave an opaque card: stack two and the front one's gaps hide the back one behind flat\n // page colour instead of showing it. Here the gaps only carry that colour as far as\n // uLineGapOpacity and are otherwise transparent, so the strands composite over whatever is really\n // behind them — the next wave in the stack, a solid wave used as a dark backing, or the page.\n //\n // Straight alpha-over, unpremultiplied: the visible colour is the strand and the gap weighted by\n // their coverages, divided back out by the total so the result is a colour rather than a\n // premultiplied one (Three's own PREMULTIPLIED_ALPHA step below does that part).\n float gapA = (1.0 - cov) * uLineGapOpacity;\n float outA = cov + gapA;\n // A fully clear gap must not reach the depth buffer, or it would occlude the wave behind it just\n // as the opaque version did. Strand EDGES keep their partial alpha (and their depth), which is a\n // pixel either side and exactly what antialiasing them is for.\n if (outA <= 0.002) discard;\n color = (color * cov + uClearColor * gapA) / outA;\n if (uSquared > 0.5) color *= color; // deep \"squared\" look, now composited not replace-blended\n gl_FragColor = vec4(color, uOpacity * outA);\n#else\n color = mix(uClearColor, color, cov);\n if (uSquared > 0.5) color *= color; // deep \"squared\" look, now composited not replace-blended\n gl_FragColor = vec4(color, uOpacity);\n#endif\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\n// ---------------------------------------------------------------------------------------------\n// Particle field (additive dust / sparkle) — ONE per wave. A THREE.Points ShaderMaterial: every\n// particle's position + life is a pure function of uTime + baked per-particle attributes (aSeed / aRnd\n// / aUv), so the whole field is deterministic (timeOffset scrub / loopSeconds / paused all hold).\n// Every particle spawns on the OWNING wave's DEFORMED surface / edge (via the shared waveShape chunk,\n// riding the exact deform the ribbon uses) and drifts outward from the wave centre as it ages. The\n// wave's shape #defines (HELIX/RADIAL/…) are mirrored onto this material in configure().\n// ---------------------------------------------------------------------------------------------\nexport const particleVertexShader = /* glsl */ `\nattribute float aSeed;\nattribute vec4 aRnd;\nattribute vec2 aUv; // where this particle spawns on the ribbon (x = flank, y = along length; edge-biased at build)\n\nuniform float uTime, uLoopSeconds, uLife, uSize, uSizeJitter, uTwinkle, uPixelRatio;\nuniform float uPartSpeed;\nuniform vec3 uColor, uColor2, uCenter, uRight, uUp;\nuniform float uDrift, uRise, uSwirl, uWander;\n\n// The owning wave's shape, mirrored in configure() so the dust rides the SAME deform as the ribbon.\n// The HELIX/RADIAL uniform blocks are declared only when the matching #define is set.\n${simplex2d}\nuniform float uDispFreqX, uDispFreqZ, uDispAmount;\nuniform float uDetailFreq, uDetailAmount;\nuniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;\n#ifdef HELIX\nuniform float uHelixTurns, uHelixRadius, uHelixRoll, uHelixPhase;\n#endif\n#ifdef PATH\nuniform sampler2D uPathTex;\n#endif\n#ifdef RADIAL\nuniform float uRadialAmount, uRadialArc, uRadialSpread, uRadialRadius, uRadialCenter, uRadialCone, uRadialSwirl;\n#endif\nuniform mat4 uShedModel; // the wave's matrixWorld (deformed LOCAL → world)\nuniform float uShedSpeed, uShedSeed;\n${waveShapeChunk}\n\n// The owning wave's disintegration front, mirrored the same way, so a mote peels off exactly where\n// and when the surface under it crumbles. uDissolveDust is the particle-only knob (0 = ignore the\n// front and free-run on uLife, as a field with no dissolve always has).\n#ifdef DISSOLVE\n${dissolveChunk}\nuniform float uDissolveDust;\n#endif\n\n// The cursor. Same chunk the ribbon uses, mirrored onto this material in ParticleField.configure(),\n// and behind the same POINTER_FX gate — a wave with no hover field compiles the point program it\n// always did. uPartShove is the one particle-only knob (see the two samples in main).\n#ifdef POINTER_FX\n${pointerFieldChunk}\nuniform float uPartShove; // how hard the cursor shoves dust that has already drifted free (0 = off)\n#endif\n\nvarying float vAlpha;\nvarying vec3 vColor;\nvarying vec2 vDir; // screen-space motion direction (for the streak sprite)\nvarying float vSeed; // this particle's seed (the square sprite cuts its own shard from it)\n\nconst float TAU = 6.28318530718;\n\nvoid main(){\n // Deterministic life: age 0..1 from uTime + a per-particle seed. Advances once per loop period when\n // looping (so the whole field repeats seamlessly), else once per uLife seconds. uPartSpeed scales the\n // cadence (motion speed); under a loop it snaps to a whole number of cycles so the seam stays seamless.\n float cyc = max(1.0, floor(uPartSpeed + 0.5));\n float rate = (uLoopSeconds > 0.0) ? (uTime / uLoopSeconds * cyc) : (uTime * uPartSpeed / max(uLife, 0.001));\n float age = fract(rate + aSeed);\n float fade = sin(3.14159265 * age); // 0 at birth/death, 1 mid-life\n\n // Spawn on the owning wave's DEFORMED surface / edge at aUv (via the shared waveShape), then peel\n // outward from the wave centre as the particle ages — silk dissolving into glitter.\n float ts = uTime * uShedSpeed + uShedSeed;\n vec2 loopOff = vec2(0.0);\n#ifdef LOOP_MOTION\n float loopTheta = uTime * (TAU / uLoopSeconds) + uShedSeed;\n float loopR = uShedSpeed * uLoopSeconds * 0.159154943092;\n loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));\n ts = 0.0;\n#endif\n // Approximate the base hairpin point for this uv (length from uv.y; width centre), then deform it\n // exactly as the wave does. Good enough for dust — the fan / displacement dominate.\n vec3 base = vec3((aUv.y - 0.5) * 400.0, 0.0, ${RIBBON_Z_CENTER.toFixed(1)});\n WaveShape ws = waveShape(base, aUv, ts, loopOff);\n vec3 origin = (uShedModel * vec4(ws.pos, 1.0)).xyz;\n vec3 outward = normalize(origin - uCenter + vec3(1e-4));\n // (DISSOLVE may re-aim this below — see the debris sweep.)\n#ifdef DISSOLVE\n // Pinned to the wave's dissolve front: this mote IS the chunk of surface that just left, so it\n // does not exist until the front reaches its patch, then peels off and drifts on from there.\n // age becomes its progress past the front rather than a free-running clock — which is what makes\n // the dust and the holes in the ribbon one event instead of two effects that happen to overlap.\n {\n float band = max(uDissolveBand, 1.0e-3);\n // Stagger: nudge each mote's own front, and give it its own peel rate, so a band does not lift\n // off as one flat sheet.\n vec4 dClip = projectionMatrix * viewMatrix * vec4(origin, 1.0);\n vec2 dNdc = dClip.xy / max(dClip.w, 1.0e-6) * 0.5 + 0.5;\n float c = dissolveCoord(aUv, dNdc) + (aRnd.x - 0.5) * band * 0.9;\n float front = uDissolveAmount * (1.0 + band);\n float peel = clamp((front - c) / (band * (0.4 + aRnd.y * 1.2)), 0.0, 1.0);\n age = mix(age, peel, uDissolveDust);\n // Visible from the moment the front takes it, then a long tail out as it travels.\n float f = smoothstep(0.0, 0.06, peel) * (1.0 - smoothstep(0.55, 1.0, peel));\n fade = mix(fade, f, uDissolveDust);\n // Debris is thrown along the sweep, AWAY from the part still standing — under a screen-axis\n // front the whole cloud blows one way across the frame instead of radiating off the wave centre\n // in every direction (which puts dust back over the half that has not crumbled yet). Only the\n // screen axes have a direction to borrow; a uv front keeps radiating, which is what a ribbon\n // fraying along its own length should do.\n if (uDissolveAxis > 1.5) {\n vec3 sweep = (uDissolveAxis > 2.5 ? uUp : uRight) * (uDissolveReverse > 0.5 ? 1.0 : -1.0);\n outward = normalize(mix(outward, sweep, uDissolveDust) + vec3(1e-4));\n }\n }\n#endif\n\n#ifdef POINTER_FX\n // WELD (applied below, once the mote's own motion is known). The ribbon displaces its surface by\n // pointerField() along its own post-twist up-axis; a mote sitting ON that surface has to take the\n // same ride, or the cursor's dome lifts the silk out from under its own glitter. Sampled at the\n // SPAWN point and carried through the local→world matrix exactly as the ribbon's own\n // pos += dispAxis * disp is, so the two land on the same place. Sampling at the spawn point also\n // leaves outward derived from the UNDISPLACED origin, so a poke never bends the drift direction.\n mat4 pMvp = projectionMatrix * viewMatrix * uShedModel;\n vec4 originClip = pMvp * vec4(ws.pos, 1.0);\n vec3 dispAxis = mat3(uShedModel) * (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;\n PointerHit weld = pointerField(originClip.xy / max(originClip.w, 1.0e-6), pMvp,\n ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);\n#endif\n\n vec3 p = origin + outward * age * uDrift + (aRnd.xyz - 0.5) * age * uDrift * 0.35;\n\n // Motion styles, each 0 = off, all riding age so they stay loop-safe (the age wrap is hidden by\n // fade→0 at birth/death). rise = screen-vertical buoyancy (embers up / snow down); swirl = orbit\n // around the wave centre in the screen plane; wander = curl-noise turbulence (fireflies / motes).\n p += uUp * age * uRise;\n // How far this mote travels away from its birth patch over a WHOLE life — the straight-line terms\n // plus, under swirl, the arc it sweeps at its own orbit radius. Only the pointer weld reads it\n // (0 for dust that merely clings to the surface, which is exactly the case age would get wrong),\n // so it is fenced like everything else the cursor drives.\n#ifdef POINTER_FX\n float span = abs(uDrift) * 1.35 + abs(uRise) + uWander;\n#endif\n if (uSwirl != 0.0) {\n vec3 nrm = cross(uRight, uUp);\n vec3 rel = p - uCenter;\n#ifdef POINTER_FX\n span += abs(uSwirl) * TAU * length(rel);\n#endif\n float rx = dot(rel, uRight), ry = dot(rel, uUp), rz = dot(rel, nrm);\n float a = age * uSwirl * TAU;\n float ca = cos(a), sa = sin(a);\n p = uCenter + uRight * (rx * ca - ry * sa) + uUp * (rx * sa + ry * ca) + nrm * rz;\n }\n if (uWander != 0.0) {\n vec2 wan = vec2(simplexNoise(vec2(aSeed * 17.0, age * 3.0)),\n simplexNoise(vec2(age * 3.0, aSeed * 23.0)));\n p += (uRight * wan.x + uUp * wan.y) * uWander;\n }\n\n#ifdef POINTER_FX\n // How attached to its birth patch this mote still is: 1 while it sits on the surface, 0 once it\n // has travelled a full life's worth away. Measured from the DISTANCE it actually moved rather than\n // from age, because dust with no drift / rise / swirl / wander never leaves the surface at all —\n // an age fade would quietly stop that dust from following the ribbon halfway through its life.\n float attach = span > 1.0e-4 ? 1.0 - clamp(length(p - origin) / span, 0.0, 1.0) : 1.0;\n p += dispAxis * (weld.disp * attach);\n // SHOVE: the exact complement. The same field sampled at the mote's OWN screen position, so the\n // cursor also pushes dust that has already left the surface — and a click ripple visibly blows\n // through the cloud instead of stopping dead at the ribbon. Uniform branch (warp-coherent), so\n // uPartShove 0 costs nothing.\n if (uPartShove != 0.0) {\n vec4 pClip = projectionMatrix * viewMatrix * vec4(p, 1.0);\n PointerHit shove = pointerField(pClip.xy / max(pClip.w, 1.0e-6), pMvp,\n ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);\n p += dispAxis * (shove.disp * (1.0 - attach) * uPartShove);\n }\n#endif\n\n float tw = 0.5 + 0.5 * sin((age * 9.0 + aSeed) * TAU); // loop-safe flicker (rides age)\n vAlpha = fade * mix(1.0, tw, clamp(uTwinkle, 0.0, 1.0));\n vColor = mix(uColor, uColor2, aRnd.w); // two-tone dust: per-particle blend of the two colours\n vDir = normalize(vec2(dot(outward, uRight), dot(outward, uUp)) + vec2(1e-4)); // outward, in screen space\n vSeed = aSeed;\n gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);\n // Orthographic camera → point size is constant in device pixels (no perspective depth divide).\n float jitter = 1.0 + uSizeJitter * (aSeed - 0.5) * 2.0;\n gl_PointSize = max(uSize * uPixelRatio * jitter * fade, 0.0);\n}\n`;\n\nexport const particleFragmentShader = /* glsl */ `\nprecision highp float;\nuniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak · 5 square\nvarying float vAlpha;\nvarying vec3 vColor;\nvarying vec2 vDir;\nvarying float vSeed; // this particle's seed — the square sprite cuts its own shard from it\n// User artwork (shape \"sprite\"), behind a define so a field without one compiles the exact same\n// program — and so the sampler only exists once a texture is actually bound to it. ONE texture is\n// shared by every particle in the field; see ParticleField.loadSprite for the rasterization.\n#ifdef PARTICLE_SPRITE\nuniform sampler2D uSprite;\n#endif\nvoid main(){\n#ifdef PARTICLE_SPRITE\n // gl_PointCoord's origin is the sprite's TOP-left with y running DOWN, so it has to be flipped or\n // every sprite draws upside down. The procedural shapes below never needed this — they are all\n // symmetric about y, which is exactly why the bug would have gone unnoticed.\n vec4 tex = texture2D(uSprite, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));\n float a = tex.a * vAlpha;\n if (a <= 0.0) discard;\n // Tinted by the dust colour so color / color2 keep working: white artwork takes the tint\n // exactly, coloured artwork multiplies it.\n gl_FragColor = vec4(vColor * tex.rgb, a);\n#else\n vec2 pc = gl_PointCoord - 0.5;\n float d = length(pc);\n int s = int(uShape + 0.5);\n float a;\n if (s == 1) { // soft: a diffuse gaussian blob (motes / pollen)\n a = exp(-d * d * 7.0);\n } else if (s == 2) { // ring: a hollow band (bubbles)\n a = smoothstep(0.09, 0.0, abs(d - 0.34));\n } else if (s == 3) { // star: a 4-point sparkle\n float ang = atan(pc.y, pc.x);\n float spike = pow(abs(cos(ang * 2.0)), 6.0);\n a = smoothstep(1.0, 0.0, d / (0.14 + 0.5 * spike));\n } else if (s == 4) { // streak: an elongated comet along the motion direction\n float along = dot(pc, vDir);\n float perp = dot(pc, vec2(-vDir.y, vDir.x));\n a = smoothstep(0.5, 0.0, length(vec2(along * 0.42, perp * 2.2)));\n } else if (s == 5) { // square: a hard-edged chip of debris\n // A rectangle, not a disc: Chebyshev distance in place of Euclidean, screen-aligned because a\n // point sprite already is. But a field of IDENTICAL squares reads as grain rather than debris,\n // so each one cuts its own shard out of its quad — its own extent, proportion and quarter-turn,\n // from three hashes of the particle seed. The extent is SQUARED, which gives the heavy tail real\n // rubble has: mostly small chips with a few big slabs among them, rather than one uniform size.\n float h1 = fract(sin(vSeed * 127.1) * 43758.5453);\n float h2 = fract(sin(vSeed * 311.7) * 24634.6345);\n float h3 = fract(sin(vSeed * 74.7) * 39158.5453);\n float ext = mix(0.10, 0.47, h1 * h1);\n float asp = mix(0.38, 1.0, h2);\n vec2 q = h3 > 0.5 ? pc.yx : pc; // half the shards are bars the other way round\n float m = max(abs(q.x) / asp, abs(q.y));\n // Antialias over one pixel of the sprite quad, so a 3px chip has a clean edge and a 30px slab\n // is not blurred by a fixed ramp sized for the small ones.\n float w = max(fwidth(m), 1.0e-4);\n a = 1.0 - smoothstep(ext - w, ext + w, m);\n } else { // glitter (0): the soft round additive disc\n a = smoothstep(0.5, 0.0, d);\n }\n a *= vAlpha;\n if (a <= 0.0) discard;\n gl_FragColor = vec4(vColor, a); // AdditiveBlending (src = SrcAlpha) → adds vColor·a\n#endif\n}\n`;\n"],"mappings":";;;;;;;;;;;;;;;AAuBA,MAAM,YAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoC7B,MAAM,gBAA2B;;;;;;;;;;;;;;;;;;;;;;;;AA2BjC,MAAM,WAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2F5B,MAAM,iBAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAkDiB,IAAA,CAAA,QAAQ,CAAC,EAAE;;YAElC,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCA0CY,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kDA+BZ,KAAA,IAAA,CAAoB,QAAQ,CAAC,EAAE,KAAK,MAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;;;;;4BAKtE,KAAG,QAAQ,CAAC,EAAE;;;sCAGP,KAAA,QAAQ,CAAC,EAAE;wCACN,KAAG,QAAQ,CAAC,EAAE,aAAA,KAA0B,QAAQ,CAAC,EAAE;sDACrD,KAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;sDAC5B,KAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;uDAC3B,MAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;uDAC5B,MAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;;gEAEnB,MAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;gEAC5B,MAAA,EAAA,CAAiB,QAAQ,CAAC,EAAE;;;2BAGjD,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;AAwBtD,MAAM,gBAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DjC,MAAM,oBAA+B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiGrC,MAAa,eAA0B;EACrC,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqDV,kBAAkB;;;;EAIlB,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwFjB,MAAa,iBAA4B;;;;;;;EAOvC,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgDd,cAAc;;;EAGd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6KX,MAAa,sBAAiC;;;;;;EAM5C,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2Cd,cAAc;;;EAGd,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkSX,MAAa,2BAAsC;;;;;EAKjD,UAAU;;EAEV,cAAc;;;;;;;;EAQd,cAAc;;;;;;;;;;AAiBhB,MAAa,qBAAgC;;;;;;EAM3C,UAAU;;EAEV,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiDd,SAAS;;;EAGT,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwKhB,MAAa,mBAA8B;;;;;;;AAQ3C,MAAa,qBAAgC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkD7C,MAAa,uBAAkC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqD/C,MAAa,2BAAsC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCnD,MAAa,yBAAoC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCjD,MAAa,wBAAmC;;;;;;;;;;;;;;;;;;AAoBhD,MAAa,6BAAwC;;;;;;;;;;;;;;;AAiBrD,MAAa,6BAAwC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CrD,MAAa,uBAAkC;;;;;;;;;;;;EAY7C,UAAU;;;;;;;;;;;;;;;EAeV,eAAe;;;;;;EAMf,cAAc;;;;;;;;EAQd,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iDAgC6C,IAAA,CAAA,QAAQ,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8G5E,MAAa,yBAAoC"}
@@ -0,0 +1,56 @@
1
+ import { simplexNoise } from "./noise.js";
2
+ import { clamp, dot, float, floor, fract, mix, select, sin, vec2 } from "three/tsl";
3
+ //#region src/renderer/tsl/dissolve.ts
4
+ /**
5
+ * The disintegration front in TSL — the port of `dissolveChunk` in `../shaders.ts`.
6
+ *
7
+ * A band sweeps across the ribbon and everything behind it is eaten away chunk by chunk. Both wave
8
+ * fragments discard on {@link dissolved}; the particle emitter reads {@link dissolveProgress} on the
9
+ * same front, so the dust it sheds leaves exactly where the surface goes.
10
+ *
11
+ * Kept in one module (rather than inlined at each call site) for the same reason the GLSL keeps one
12
+ * chunk: the ribbon and its dust MUST agree on where the front is, to the last decimal, or the
13
+ * chunks and the motes drift apart.
14
+ */
15
+ /**
16
+ * The sweep coordinate, 0 where the front starts and 1 where it ends.
17
+ *
18
+ * Two families: the RIBBON's own axes (uv), so the front follows the sheet wherever the twist takes
19
+ * it, and SCREEN space (ndc, 0..1 across the frame), so the front is a straight line on the canvas
20
+ * and every wave in a stack crumbles against the same edge. The crumb pattern always stays in uv,
21
+ * so the chunks belong to the surface either way.
22
+ */
23
+ function dissolveCoord(u, uv, ndc) {
24
+ const c = select(u.uDissolveAxis.lessThan(.5), uv.y, select(u.uDissolveAxis.lessThan(1.5), uv.x, select(u.uDissolveAxis.lessThan(2.5), ndc.x, ndc.y)));
25
+ return select(u.uDissolveReverse.greaterThan(.5), float(1).sub(c), c);
26
+ }
27
+ /** How far the front has passed a point: 0 ahead of it (intact), 1 fully behind it (gone). */
28
+ function dissolveProgress(u, coord) {
29
+ const band = u.uDissolveBand.max(.001);
30
+ return clamp(u.uDissolveAmount.mul(float(1).add(band)).sub(coord).div(band), 0, 1);
31
+ }
32
+ /** Per-chunk hash: the same cell always returns the same value, so a chunk that has crumbled stays
33
+ * crumbled as the front advances (it never flickers back). */
34
+ function dissolveHash(cell) {
35
+ return fract(sin(dot(floor(cell), vec2(127.1, 311.7))).mul(43758.5453));
36
+ }
37
+ /**
38
+ * The erosion grain at a uv: 0 = the first thing to go, 1 = the last. Two octaves (coarse chunks
39
+ * with finer grit inside them) blended between smooth simplex (organic tatters) and quantized cells
40
+ * (hard blocky debris). Cells are square ON THE RIBBON — the sheet is 400 long by ~188 wide, so
41
+ * uv.y is stretched by that ratio.
42
+ */
43
+ function dissolveGrain(u, uv) {
44
+ const cell = vec2(uv.x, uv.y.mul(2.13)).mul(u.uDissolveScale).toVar("disCell");
45
+ const coarse = mix(simplexNoise(cell).mul(.5).add(.5), dissolveHash(cell), u.uDissolveBlocky);
46
+ const fine = mix(simplexNoise(cell.mul(3.7)).mul(.5).add(.5), dissolveHash(cell.mul(3.7)), u.uDissolveBlocky);
47
+ return clamp(coarse.mul(.72).add(fine.mul(.28)), 0, 1);
48
+ }
49
+ /** True where the surface has been eaten away. `ndc` is the fragment's 0..1 screen position. */
50
+ function dissolved(u, uv, ndc) {
51
+ return dissolveProgress(u, dissolveCoord(u, uv, ndc)).greaterThan(dissolveGrain(u, uv));
52
+ }
53
+ //#endregion
54
+ export { dissolveCoord, dissolveProgress, dissolved };
55
+
56
+ //# sourceMappingURL=dissolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dissolve.js","names":[],"sources":["../../../src/renderer/tsl/dissolve.ts"],"sourcesContent":["/**\n * The disintegration front in TSL — the port of `dissolveChunk` in `../shaders.ts`.\n *\n * A band sweeps across the ribbon and everything behind it is eaten away chunk by chunk. Both wave\n * fragments discard on {@link dissolved}; the particle emitter reads {@link dissolveProgress} on the\n * same front, so the dust it sheds leaves exactly where the surface goes.\n *\n * Kept in one module (rather than inlined at each call site) for the same reason the GLSL keeps one\n * chunk: the ribbon and its dust MUST agree on where the front is, to the last decimal, or the\n * chunks and the motes drift apart.\n */\nimport { float, vec2, sin, dot, floor, fract, clamp, mix, select } from \"three/tsl\";\nimport { simplexNoise } from \"./noise\";\nimport type { FloatNode, Vec2Node } from \"./types\";\nimport type { WaveTslUniforms } from \"./uniforms\";\n\n/**\n * The sweep coordinate, 0 where the front starts and 1 where it ends.\n *\n * Two families: the RIBBON's own axes (uv), so the front follows the sheet wherever the twist takes\n * it, and SCREEN space (ndc, 0..1 across the frame), so the front is a straight line on the canvas\n * and every wave in a stack crumbles against the same edge. The crumb pattern always stays in uv,\n * so the chunks belong to the surface either way.\n */\nexport function dissolveCoord(u: WaveTslUniforms, uv: Vec2Node, ndc: Vec2Node): FloatNode {\n const c = select(\n u.uDissolveAxis.lessThan(0.5),\n uv.y,\n select(\n u.uDissolveAxis.lessThan(1.5),\n uv.x,\n select(u.uDissolveAxis.lessThan(2.5), ndc.x, ndc.y),\n ),\n );\n return select(u.uDissolveReverse.greaterThan(0.5), float(1).sub(c), c);\n}\n\n/** How far the front has passed a point: 0 ahead of it (intact), 1 fully behind it (gone). */\nexport function dissolveProgress(u: WaveTslUniforms, coord: FloatNode): FloatNode {\n const band = u.uDissolveBand.max(1.0e-3);\n // amount 0 puts the band entirely BEFORE the ribbon and amount 1 entirely past it, so the two\n // ends of the range mean \"whole\" and \"gone\" whatever the band width.\n const front = u.uDissolveAmount.mul(float(1).add(band));\n return clamp(front.sub(coord).div(band), 0, 1);\n}\n\n/** Per-chunk hash: the same cell always returns the same value, so a chunk that has crumbled stays\n * crumbled as the front advances (it never flickers back). */\nfunction dissolveHash(cell: Vec2Node): FloatNode {\n return fract(sin(dot(floor(cell), vec2(127.1, 311.7))).mul(43758.5453));\n}\n\n/**\n * The erosion grain at a uv: 0 = the first thing to go, 1 = the last. Two octaves (coarse chunks\n * with finer grit inside them) blended between smooth simplex (organic tatters) and quantized cells\n * (hard blocky debris). Cells are square ON THE RIBBON — the sheet is 400 long by ~188 wide, so\n * uv.y is stretched by that ratio.\n */\nfunction dissolveGrain(u: WaveTslUniforms, uv: Vec2Node): FloatNode {\n const cell = vec2(uv.x, uv.y.mul(2.13)).mul(u.uDissolveScale).toVar(\"disCell\");\n const coarse = mix(simplexNoise(cell).mul(0.5).add(0.5), dissolveHash(cell), u.uDissolveBlocky);\n const fine = mix(\n simplexNoise(cell.mul(3.7)).mul(0.5).add(0.5),\n dissolveHash(cell.mul(3.7)),\n u.uDissolveBlocky,\n );\n return clamp(coarse.mul(0.72).add(fine.mul(0.28)), 0, 1);\n}\n\n/** True where the surface has been eaten away. `ndc` is the fragment's 0..1 screen position. */\nexport function dissolved(u: WaveTslUniforms, uv: Vec2Node, ndc: Vec2Node): FloatNode {\n return dissolveProgress(u, dissolveCoord(u, uv, ndc)).greaterThan(\n dissolveGrain(u, uv),\n ) as unknown as FloatNode;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,cAAc,GAAoB,IAAc,KAA0B;CACxF,MAAM,IAAI,OACR,EAAE,cAAc,SAAS,EAAG,GAC5B,GAAG,GACH,OACE,EAAE,cAAc,SAAS,GAAG,GAC5B,GAAG,GACH,OAAO,EAAE,cAAc,SAAS,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC,CACpD,CACF;CACA,OAAO,OAAO,EAAE,iBAAiB,YAAY,EAAG,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC;AACvE;;AAGA,SAAgB,iBAAiB,GAAoB,OAA6B;CAChF,MAAM,OAAO,EAAE,cAAc,IAAI,IAAM;CAIvC,OAAO,MADO,EAAE,gBAAgB,IAAI,MAAM,CAAC,CAAC,CAAC,IAAI,IAAI,CACpC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,IAAI,IAAI,GAAG,GAAG,CAAC;AAC/C;;;AAIA,SAAS,aAAa,MAA2B;CAC/C,OAAO,MAAM,IAAI,IAAI,MAAM,IAAI,GAAG,KAAK,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC;AACxE;;;;;;;AAQA,SAAS,cAAc,GAAoB,IAAyB;CAClE,MAAM,OAAO,KAAK,GAAG,GAAG,GAAG,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC,MAAM,SAAS;CAC7E,MAAM,SAAS,IAAI,aAAa,IAAI,CAAC,CAAC,IAAI,EAAG,CAAC,CAAC,IAAI,EAAG,GAAG,aAAa,IAAI,GAAG,EAAE,eAAe;CAC9F,MAAM,OAAO,IACX,aAAa,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,EAAG,CAAC,CAAC,IAAI,EAAG,GAC5C,aAAa,KAAK,IAAI,GAAG,CAAC,GAC1B,EAAE,eACJ;CACA,OAAO,MAAM,OAAO,IAAI,GAAI,CAAC,CAAC,IAAI,KAAK,IAAI,GAAI,CAAC,GAAG,GAAG,CAAC;AACzD;;AAGA,SAAgB,UAAU,GAAoB,IAAc,KAA0B;CACpF,OAAO,iBAAiB,GAAG,cAAc,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,YACpD,cAAc,GAAG,EAAE,CACrB;AACF"}