/** * GLSL shaders for the particle-field hero. * * Shaders are exported as tagged-template strings with a leading * `/* glsl *\/` comment marker so future syntax highlighters or * static analysers can pick them up without us adding a webpack loader. * * Conventions: * - All positions live in clip-space-like coordinates: x, y ∈ [-1, +1]. * - Position texture is RGBA32F: * r = x * g = y * b = scale (per-particle render size jitter) * a = velocity magnitude (used for color tint) * - Simulation runs in a fullscreen quad pass — each fragment = one particle. */ const simplexNoise = /* glsl */ ` // 2D simplex noise by Ian McEwan / Ashima Arts — public domain. // Used both for idle drift in the sim and for organic distortion of // the cursor-tracking ring. vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); } float snoise(vec2 v) { const vec4 C = vec4( 0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439 ); vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx); vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i); vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); m = m * m; m = m * m; vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox; m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw; return 130.0 * dot(m, g); } `; /** * Sim vertex shader — trivial fullscreen pass. * Writes through clip-space UVs so the fragment shader receives one * fragment per particle in the position texture. */ export const simVertex = /* glsl */ ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `; /** * Sim fragment shader — the actual integrator. * * Inputs: * uPrev — previous-frame position texture (ping-pong source) * uTime — elapsed seconds * uDelta — clamped frame delta (seconds), guards against tab-switch spikes * uRingPos — mouse position in clip space, smoothed * uRingRadius— current ring radius (clip-space units) * uRingWidth — base ring thickness (clip-space units) * uRingActive— 0..1 fade so the ring softly vanishes when the mouse leaves * uMotionScale— global multiplier on drift + ring-push velocity. 1.0 is * default; the prefers-reduced-motion path passes 0.5 so * the field reads as calm without removing interaction. * Pointer *position* is not scaled — the ring still * tracks the cursor at full fidelity. * * Per-particle dynamics: * 1. Idle drift: rotational simplex-noise velocity field — feels like * slow oceanic currents rather than random brownian jitter. * 2. Ring push: three overlapping smoothstep bands at slightly offset * radii, with the radius input distorted by simplex noise and a * polar sin/cos wave. The gradient of the resulting field is * applied as an outward push, so particles get gently shoved as * the ring sweeps over them. * 3. Containment: a very soft spring pulls particles back toward the * origin if they drift past the field edge — prevents particles * from escaping to infinity on long sessions. * 4. Damping: every frame velocity decays so the field returns to a * calm steady state when the mouse is idle. */ export const simFragment = /* glsl */ ` precision highp float; uniform sampler2D uPrev; uniform float uTime; uniform float uDelta; uniform vec2 uRingPos; uniform float uRingRadius; uniform float uRingWidth; uniform float uRingActive; uniform float uMotionScale; varying vec2 vUv; ${simplexNoise} // Organic ring field — value peaks ON the ring, falls off either side. // Three overlapping smoothstep bands with simplex-noise + polar-wave // distortion to keep the boundary breathing instead of geometric. float ringField(vec2 p) { vec2 d = p - uRingPos; float r = length(d); float ang = atan(d.y, d.x); // Breathing distortion of the radius itself. Time scale tuned // down (was 0.35 → 0.22) so the ring "breathes" rather than // pulsates, which used to read as visual stutter on slow drift. float noise = snoise(p * 4.0 + uTime * 0.22) * 0.05; // Polar wave — a slow rippling around the circumference. // Same calming pass on both phases. float wave = sin(ang * 5.0 + uTime * 0.7) * 0.012 + cos(ang * 3.0 - uTime * 0.42) * 0.010; float rr = r + noise + wave; // Three bands of different thickness at slightly offset radii. float w = uRingWidth; float b1 = smoothstep(uRingRadius - w * 0.30, uRingRadius, rr) * (1.0 - smoothstep(uRingRadius, uRingRadius + w * 0.30, rr)); float b2 = smoothstep(uRingRadius - w * 0.80, uRingRadius - w * 0.15, rr) * (1.0 - smoothstep(uRingRadius - w * 0.15, uRingRadius + w * 0.65, rr)); float b3 = smoothstep(uRingRadius - w * 1.40, uRingRadius - w * 0.50, rr) * (1.0 - smoothstep(uRingRadius - w * 0.50, uRingRadius + w * 1.20, rr)); return (b1 * 1.0 + b2 * 0.55 + b3 * 0.30) * uRingActive; } void main() { vec4 prev = texture2D(uPrev, vUv); vec2 pos = prev.xy; float scale = prev.z; float velPrev = prev.w; // --- Idle drift: rotational simplex-noise current --- // Time is scaled by uMotionScale so reduced-motion users get a // calmer field that evolves at half speed. // Noise-evolution speed dropped from 0.08 → 0.045 (almost halved) // and velocity magnitude from 0.045 → 0.028. The combination kills // the perceived stutter — each particle now moves slowly enough // that the eye tracks individual motion as smooth drift rather // than as jerky per-frame teleportation. float driftTime = uTime * uMotionScale; float n1 = snoise(pos * 1.6 + vec2(driftTime * 0.045, 0.0)); float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.045) + 53.7); vec2 driftVel = vec2(-n2, n1) * 0.028 * uMotionScale; // curl-like rotation // --- Ring push: gradient of the ring field, pointing outward --- float h = 0.003; float fx0 = ringField(pos - vec2(h, 0.0)); float fx1 = ringField(pos + vec2(h, 0.0)); float fy0 = ringField(pos - vec2(0.0, h)); float fy1 = ringField(pos + vec2(0.0, h)); vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h); float fieldHere = ringField(pos); // Push along gradient — particles get nudged away from the ring crest. // Magnitude is scaled by uMotionScale so reduced-motion users get a // softer shove while the ring position still tracks at full fidelity. vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale; // --- Soft containment toward origin if particle escaped --- float r = length(pos); vec2 containVel = vec2(0.0); if (r > 1.05) { containVel = -normalize(pos) * (r - 1.05) * 0.6; } // --- Integrate --- vec2 vel = driftVel + ringVel + containVel; vec2 next = pos + vel * uDelta * 60.0; // normalise to 60fps reference // Velocity magnitude for color tint — EMA so flash decays gracefully. float velMag = length(vel); float velOut = mix(velPrev, velMag, 0.20); gl_FragColor = vec4(next, scale, velOut); } `; /** * Render vertex shader — one vertex per particle, sampled from the * position texture. The vertex's `position` attribute is unused; * instead `aIndexUv` carries the (u, v) coordinate of this particle * inside the position texture, and we read the actual position from * `uPositions`. * * `gl_PointSize` is scaled by per-particle `scale` (z channel) and the * device pixel ratio so the disc stays the same physical size on * retina displays. */ export const renderVertex = /* glsl */ ` precision highp float; uniform sampler2D uPositions; uniform float uPointSize; uniform float uDpr; attribute vec2 aIndexUv; varying float vVel; varying float vScale; void main() { vec4 p = texture2D(uPositions, aIndexUv); vScale = p.z; vVel = p.w; // Position is already clip-space xy in [-1, +1]; pin z = 0. gl_Position = vec4(p.xy, 0.0, 1.0); gl_PointSize = uPointSize * p.z * uDpr; } `; /** * Render fragment shader — anti-aliased disc + velocity-based tint. * * Color: most of the field stays calm indigo at low opacity; particles * that just got shoved by the ring (high velocity) flash toward a * success-green tint. Output is premultiplied so additive blending * gives the bloom-like glow without needing a post-process pass. */ export const renderFragment = /* glsl */ ` precision highp float; uniform vec3 uColorCalm; // indigo uniform vec3 uColorHot; // success-green uniform float uBaseAlpha; varying float vVel; varying float vScale; void main() { // Disc SDF — anti-aliased round dot. float d = length(gl_PointCoord - 0.5); float a = smoothstep(0.5, 0.42, d); if (a <= 0.001) discard; // Velocity-driven mix: pin to indigo for typical drift, lerp toward // green only on real shoves. The 0.04..0.18 band is roughly where // ring pushes live; idle drift stays below 0.03. float t = smoothstep(0.04, 0.18, vVel); vec3 col = mix(uColorCalm, uColorHot, t); float alpha = uBaseAlpha * a * (0.6 + 0.4 * vScale); // Premultiplied alpha — pairs with THREE.AdditiveBlending. gl_FragColor = vec4(col * alpha, alpha); } `; /** * Init fragment — runs once into both ping-pong targets to seed the * starting field. Uses a tempered random distribution: uniform in the * disc, with a small radial bias toward the edges so the field doesn't * look like a bullseye on first frame. */ export const initFragment = /* glsl */ ` precision highp float; varying vec2 vUv; ${simplexNoise} // Tiny hash for per-particle deterministic randoms. float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } void main() { float r1 = hash(vUv); float r2 = hash(vUv + 17.3); float r3 = hash(vUv + 91.7); // Polar-uniform disc with a soft outward bias. float angle = r1 * 6.28318; float radius = sqrt(r2) * 1.0; vec2 pos = vec2(cos(angle), sin(angle)) * radius; // Slight horizontal stretch so the field reads as a wide hero band, // not a perfect circle. pos.x *= 1.25; float scale = 0.55 + r3 * 0.85; gl_FragColor = vec4(pos, scale, 0.0); } `;