/**
 * WebGL2 stable-fluids background — port of Pavel Dobryakov's reference
 * implementation (Navier–Stokes + multi-pass bloom). The kidney/comma
 * silhouette comes from vorticity confinement; the bright glow comes from
 * the bloom post-pass, not from raw splat brightness — splats themselves
 * stay dim (×0.15) so the bloom threshold lifts the bright cores into
 * proper highlights.
 *
 * Pipeline per frame:
 *   pointer splat → curl → vorticity → divergence → pressure (×20) →
 *   gradient subtract → advect velocity → advect dye → bloom → display.
 *
 * Mouse handling mirrors the reference: dx/dy are 5× pixel deltas, fed as
 * the velocity-splat colour. Brand colour is locked to peach-red.
 */

// Codaro neon-violet — rgb(102, 58, 243) / #663af3
const BRAND_COLOR = { r: 102 / 255, g: 58 / 255, b: 243 / 255 };
const COLOR_SCALE = 0.22;

const FluidShader = () => {
  const ref = React.useRef(null);

  React.useEffect(() => {
    const canvas = ref.current;

    // Match the canvas drawing-buffer to the device's physical pixels so
    // the dye texture isn't blocky-scaled by the browser. Cap DPR at 2 on
    // mobile so the simulation+bloom pipeline doesn't render at 3× cost
    // on Retina-class phones for marginal sharpness gain.
    const isMobile = window.matchMedia('(max-width: 720px)').matches;
    const DPR_CAP  = isMobile ? 2 : 2.5;
    const dpr      = Math.min(window.devicePixelRatio || 1, DPR_CAP);
    canvas.width   = Math.round(canvas.clientWidth  * dpr);
    canvas.height  = Math.round(canvas.clientHeight * dpr);

    const gl = canvas.getContext('webgl2', { alpha: true, depth: false, stencil: false, antialias: false, preserveDrawingBuffer: false });
    if (!gl) return;
    gl.getExtension('EXT_color_buffer_float');
    // On mobile WebGL2, RGBA16F linear filtering is spec-required, so
    // assume LINEAR works even if the legacy float-linear extension probe
    // returns null (some Android GPUs don't expose the extension name).
    const supportLinearFiltering = true;
    gl.clearColor(0, 0, 0, 1);

    /* ─── config (Pavel Dobryakov defaults) ─────────────────────────── */
    // The phone canvas is much smaller in absolute pixels than desktop,
    // so the same SPLAT_RADIUS (a normalized value) ends up looking like
    // a tiny dot. We bump radius and bloom on small screens so the puffs
    // stay roughly the same perceived size as on desktop. (`isMobile` is
    // already declared above for the DPR setup.)
    const CFG = {
      SIM_RESOLUTION:        128,
      DYE_RESOLUTION:        512,
      DENSITY_DISSIPATION:   0.97,
      VELOCITY_DISSIPATION:  0.98,
      PRESSURE_DISSIPATION:  0.8,
      PRESSURE_ITERATIONS:   20,
      CURL:                  isMobile ? 8     : 6,
      SPLAT_RADIUS:          isMobile ? 0.24  : 0.13,
      SHADING:               true,
      BLOOM:                 true,
      BLOOM_ITERATIONS:      8,
      BLOOM_RESOLUTION:      256,
      BLOOM_INTENSITY:       isMobile ? 0.75  : 0.55,
      BLOOM_THRESHOLD:       isMobile ? 0.50  : 0.55,
      BLOOM_SOFT_KNEE:       0.6,
    };

    /* ─── shader sources ────────────────────────────────────────────── */
    const VS = `#version 300 es
      precision highp float;
      layout(location = 0) in vec2 aPosition;
      out vec2 vUv;
      out vec2 vL;
      out vec2 vR;
      out vec2 vT;
      out vec2 vB;
      uniform vec2 texelSize;
      void main() {
        vUv = aPosition * 0.5 + 0.5;
        vL = vUv - vec2(texelSize.x, 0.0);
        vR = vUv + vec2(texelSize.x, 0.0);
        vT = vUv + vec2(0.0, texelSize.y);
        vB = vUv - vec2(0.0, texelSize.y);
        gl_Position = vec4(aPosition, 0.0, 1.0);
      }`;

    const FS_COPY = `#version 300 es
      precision highp float;
      in vec2 vUv;
      uniform sampler2D uTexture;
      uniform float value;
      out vec4 o;
      void main() { o = value * texture(uTexture, vUv); }`;

    const FS_DISPLAY = `#version 300 es
      precision highp float;
      in vec2 vUv;
      uniform sampler2D uTexture;
      out vec4 o;
      void main() {
        vec3 c = texture(uTexture, vUv).rgb;
        float a = max(c.r, max(c.g, c.b));
        o = vec4(c, a);
      }`;

    const FS_DISPLAY_BLOOM = `#version 300 es
      precision highp float;
      in vec2 vUv;
      uniform sampler2D uTexture;
      uniform sampler2D uBloom;
      out vec4 o;
      void main() {
        vec3 c     = texture(uTexture, vUv).rgb;
        vec3 bloom = texture(uBloom,   vUv).rgb;
        bloom = pow(bloom, vec3(1.0 / 2.2));
        c += bloom;
        float a = max(c.r, max(c.g, c.b));
        o = vec4(c, a);
      }`;

    const FS_DISPLAY_SHADING = `#version 300 es
      precision highp float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uTexture;
      uniform vec2 texelSize;
      out vec4 o;
      void main() {
        vec3 L = texture(uTexture, vL).rgb;
        vec3 R = texture(uTexture, vR).rgb;
        vec3 T = texture(uTexture, vT).rgb;
        vec3 B = texture(uTexture, vB).rgb;
        vec3 C = texture(uTexture, vUv).rgb;
        float dx = length(R) - length(L);
        float dy = length(T) - length(B);
        vec3 n = normalize(vec3(dx, dy, length(texelSize)));
        vec3 l = vec3(0.0, 0.0, 1.0);
        float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);
        C *= diffuse;
        float a = max(C.r, max(C.g, C.b));
        o = vec4(C, a);
      }`;

    const FS_DISPLAY_BLOOM_SHADING = `#version 300 es
      precision highp float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uTexture;
      uniform sampler2D uBloom;
      uniform vec2 texelSize;
      out vec4 o;
      void main() {
        vec3 L = texture(uTexture, vL).rgb;
        vec3 R = texture(uTexture, vR).rgb;
        vec3 T = texture(uTexture, vT).rgb;
        vec3 B = texture(uTexture, vB).rgb;
        vec3 C = texture(uTexture, vUv).rgb;
        float dx = length(R) - length(L);
        float dy = length(T) - length(B);
        vec3 n = normalize(vec3(dx, dy, length(texelSize)));
        vec3 l = vec3(0.0, 0.0, 1.0);
        float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);
        C *= diffuse;
        vec3 bloom = texture(uBloom, vUv).rgb;
        bloom = pow(bloom, vec3(1.0 / 2.2));
        C += bloom;
        float a = max(C.r, max(C.g, C.b));
        o = vec4(C, a);
      }`;

    const FS_BLOOM_PREFILTER = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      uniform sampler2D uTexture;
      uniform vec3 curve;
      uniform float threshold;
      out vec4 o;
      void main() {
        vec3 c = texture(uTexture, vUv).rgb;
        float br = max(c.r, max(c.g, c.b));
        float rq = clamp(br - curve.x, 0.0, curve.y);
        rq = curve.z * rq * rq;
        c *= max(rq, br - threshold) / max(br, 0.0001);
        o = vec4(c, 0.0);
      }`;

    const FS_BLOOM_BLUR = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uTexture;
      out vec4 o;
      void main() {
        vec4 sum = vec4(0.0);
        sum += texture(uTexture, vL);
        sum += texture(uTexture, vR);
        sum += texture(uTexture, vT);
        sum += texture(uTexture, vB);
        sum *= 0.25;
        o = sum;
      }`;

    const FS_BLOOM_FINAL = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uTexture;
      uniform float intensity;
      out vec4 o;
      void main() {
        vec4 sum = vec4(0.0);
        sum += texture(uTexture, vL);
        sum += texture(uTexture, vR);
        sum += texture(uTexture, vT);
        sum += texture(uTexture, vB);
        sum *= 0.25;
        o = sum * intensity;
      }`;

    const FS_SPLAT = `#version 300 es
      precision highp float;
      in vec2 vUv;
      uniform sampler2D uTarget;
      uniform float aspectRatio;
      uniform vec3 color;
      uniform vec2 point;
      uniform float radius;
      out vec4 o;
      void main() {
        vec2 p = vUv - point.xy;
        p.x *= aspectRatio;
        vec3 splat = exp(-dot(p, p) / radius) * color;
        vec3 base = texture(uTarget, vUv).xyz;
        o = vec4(base + splat, 1.0);
      }`;

    const FS_ADVECTION = `#version 300 es
      precision highp float;
      in vec2 vUv;
      uniform sampler2D uVelocity;
      uniform sampler2D uSource;
      uniform vec2 texelSize;
      uniform float dt;
      uniform float dissipation;
      out vec4 o;
      void main() {
        vec2 coord = vUv - dt * texture(uVelocity, vUv).xy * texelSize;
        o = dissipation * texture(uSource, coord);
        o.a = 1.0;
      }`;

    const FS_DIVERGENCE = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uVelocity;
      out vec4 o;
      void main() {
        float L = texture(uVelocity, vL).x;
        float R = texture(uVelocity, vR).x;
        float T = texture(uVelocity, vT).y;
        float B = texture(uVelocity, vB).y;
        vec2 C = texture(uVelocity, vUv).xy;
        if (vL.x < 0.0) L = -C.x;
        if (vR.x > 1.0) R = -C.x;
        if (vT.y > 1.0) T = -C.y;
        if (vB.y < 0.0) B = -C.y;
        float div = 0.5 * (R - L + T - B);
        o = vec4(div, 0.0, 0.0, 1.0);
      }`;

    const FS_CURL = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uVelocity;
      out vec4 o;
      void main() {
        float L = texture(uVelocity, vL).y;
        float R = texture(uVelocity, vR).y;
        float T = texture(uVelocity, vT).x;
        float B = texture(uVelocity, vB).x;
        float vorticity = R - L - T + B;
        o = vec4(0.5 * vorticity, 0.0, 0.0, 1.0);
      }`;

    const FS_VORTICITY = `#version 300 es
      precision highp float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uVelocity;
      uniform sampler2D uCurl;
      uniform float curl;
      uniform float dt;
      out vec4 o;
      void main() {
        float L = texture(uCurl, vL).x;
        float R = texture(uCurl, vR).x;
        float T = texture(uCurl, vT).x;
        float B = texture(uCurl, vB).x;
        float C = texture(uCurl, vUv).x;
        vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
        force /= length(force) + 0.0001;
        force *= curl * C;
        force.y *= -1.0;
        vec2 vel = texture(uVelocity, vUv).xy;
        o = vec4(vel + force * dt, 0.0, 1.0);
      }`;

    const FS_PRESSURE = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uPressure;
      uniform sampler2D uDivergence;
      out vec4 o;
      void main() {
        float L = texture(uPressure, vL).x;
        float R = texture(uPressure, vR).x;
        float T = texture(uPressure, vT).x;
        float B = texture(uPressure, vB).x;
        float divergence = texture(uDivergence, vUv).x;
        float pressure = (L + R + B + T - divergence) * 0.25;
        o = vec4(pressure, 0.0, 0.0, 1.0);
      }`;

    const FS_GRADIENT = `#version 300 es
      precision mediump float;
      in vec2 vUv;
      in vec2 vL; in vec2 vR; in vec2 vT; in vec2 vB;
      uniform sampler2D uPressure;
      uniform sampler2D uVelocity;
      out vec4 o;
      void main() {
        float L = texture(uPressure, vL).x;
        float R = texture(uPressure, vR).x;
        float T = texture(uPressure, vT).x;
        float B = texture(uPressure, vB).x;
        vec2 velocity = texture(uVelocity, vUv).xy;
        velocity.xy -= vec2(R - L, T - B);
        o = vec4(velocity, 0.0, 1.0);
      }`;

    /* ─── shader / program helpers ──────────────────────────────────── */
    const compile = (type, src) => {
      const s = gl.createShader(type);
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        throw new Error(gl.getShaderInfoLog(s));
      }
      return s;
    };
    const program = (vs, fs) => {
      const p = gl.createProgram();
      gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
      gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
      gl.linkProgram(p);
      if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
        throw new Error(gl.getProgramInfoLog(p));
      }
      const uniforms = {};
      const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
      for (let i = 0; i < n; i++) {
        const name = gl.getActiveUniform(p, i).name;
        uniforms[name] = gl.getUniformLocation(p, name);
      }
      return { program: p, uniforms, bind: () => gl.useProgram(p) };
    };

    const pCopy           = program(VS, FS_COPY);
    const pDisplay        = program(VS, FS_DISPLAY);
    const pDisplayBloom   = program(VS, FS_DISPLAY_BLOOM);
    const pDisplayShading = program(VS, FS_DISPLAY_SHADING);
    const pDisplayBoth    = program(VS, FS_DISPLAY_BLOOM_SHADING);
    const pBloomPre       = program(VS, FS_BLOOM_PREFILTER);
    const pBloomBlur      = program(VS, FS_BLOOM_BLUR);
    const pBloomFinal     = program(VS, FS_BLOOM_FINAL);
    const pSplat          = program(VS, FS_SPLAT);
    const pAdv            = program(VS, FS_ADVECTION);
    const pDiv            = program(VS, FS_DIVERGENCE);
    const pCurlProg       = program(VS, FS_CURL);
    const pVort           = program(VS, FS_VORTICITY);
    const pPress          = program(VS, FS_PRESSURE);
    const pGrad           = program(VS, FS_GRADIENT);

    /* ─── geometry: a single fullscreen quad ────────────────────────── */
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1,  -1, 1,  1, 1,  1, -1]), gl.STATIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2,  0, 2, 3]), gl.STATIC_DRAW);
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
    gl.enableVertexAttribArray(0);

    const blit = (target) => {
      gl.bindFramebuffer(gl.FRAMEBUFFER, target);
      gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
    };

    /* ─── FBO factories ─────────────────────────────────────────────── */
    const filter = supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
    const HF = gl.HALF_FLOAT;

    const createFBO = (w, h, internalFormat, format, type, paramFilter) => {
      gl.activeTexture(gl.TEXTURE0);
      const tex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, paramFilter);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, paramFilter);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null);
      const fbo = gl.createFramebuffer();
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
      gl.viewport(0, 0, w, h);
      gl.clear(gl.COLOR_BUFFER_BIT);
      return {
        texture: tex, fbo, width: w, height: h,
        attach(id) { gl.activeTexture(gl.TEXTURE0 + id); gl.bindTexture(gl.TEXTURE_2D, tex); return id; },
      };
    };

    const createDoubleFBO = (w, h, internalFormat, format, type, paramFilter) => {
      let r = createFBO(w, h, internalFormat, format, type, paramFilter);
      let w_ = createFBO(w, h, internalFormat, format, type, paramFilter);
      return {
        get read()  { return r;  },  set read(v)  { r  = v; },
        get write() { return w_; },  set write(v) { w_ = v; },
        swap() { const t = r; r = w_; w_ = t; },
      };
    };

    const getResolution = (resolution) => {
      let aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
      if (aspect < 1) aspect = 1 / aspect;
      const small = Math.round(resolution);
      const big   = Math.round(resolution * aspect);
      return gl.drawingBufferWidth > gl.drawingBufferHeight
        ? { width: big, height: small }
        : { width: small, height: big };
    };

    let dye, velocity, divergence_, curl_, pressure, bloom;
    const bloomBuffers = [];
    let dyeW, dyeH, simW, simH;

    const initFramebuffers = () => {
      const sim = getResolution(CFG.SIM_RESOLUTION);
      const dyeR = getResolution(CFG.DYE_RESOLUTION);
      simW = sim.width;  simH = sim.height;
      dyeW = dyeR.width; dyeH = dyeR.height;

      dye         = createDoubleFBO(dyeW, dyeH, gl.RGBA16F, gl.RGBA, HF, filter);
      velocity    = createDoubleFBO(simW, simH, gl.RG16F,   gl.RG,   HF, filter);
      divergence_ = createFBO     (simW, simH, gl.R16F,    gl.RED,  HF, gl.NEAREST);
      curl_       = createFBO     (simW, simH, gl.R16F,    gl.RED,  HF, gl.NEAREST);
      pressure    = createDoubleFBO(simW, simH, gl.R16F,    gl.RED,  HF, gl.NEAREST);

      // bloom chain
      const br = getResolution(CFG.BLOOM_RESOLUTION);
      bloom = createFBO(br.width, br.height, gl.RGBA16F, gl.RGBA, HF, filter);
      bloomBuffers.length = 0;
      for (let i = 0; i < CFG.BLOOM_ITERATIONS; i++) {
        const w = br.width  >> (i + 1);
        const h = br.height >> (i + 1);
        if (w < 2 || h < 2) break;
        bloomBuffers.push(createFBO(w, h, gl.RGBA16F, gl.RGBA, HF, filter));
      }
    };
    initFramebuffers();

    /* ─── splat ─────────────────────────────────────────────────────── */
    const splat = (x, y, dx, dy, color) => {
      gl.viewport(0, 0, simW, simH);
      pSplat.bind();
      gl.uniform1i(pSplat.uniforms.uTarget,    velocity.read.attach(0));
      gl.uniform1f(pSplat.uniforms.aspectRatio, canvas.width / canvas.height);
      gl.uniform2f(pSplat.uniforms.point,       x, y);
      gl.uniform3f(pSplat.uniforms.color,       dx, -dy, 1.0);
      gl.uniform1f(pSplat.uniforms.radius,      CFG.SPLAT_RADIUS / 100.0);
      blit(velocity.write.fbo); velocity.swap();

      gl.viewport(0, 0, dyeW, dyeH);
      gl.uniform1i(pSplat.uniforms.uTarget, dye.read.attach(0));
      gl.uniform3f(pSplat.uniforms.color,   color.r, color.g, color.b);
      blit(dye.write.fbo); dye.swap();
    };

    /* ─── pointer ───────────────────────────────────────────────────── */
    const pointer = { x: 0, y: 0, dx: 0, dy: 0, down: true, moved: false, color: { r: BRAND_COLOR.r * COLOR_SCALE, g: BRAND_COLOR.g * COLOR_SCALE, b: BRAND_COLOR.b * COLOR_SCALE } };

    const onMove = (e) => {
      const xRaw = e.clientX;
      const yRaw = e.clientY;
      pointer.moved = pointer.down;
      pointer.dx = 5 * (xRaw - pointer.x);
      pointer.dy = 5 * (yRaw - pointer.y);
      pointer.x  = xRaw;
      pointer.y  = yRaw;
    };
    const onTouch = (e) => {
      const t = e.targetTouches[0];
      if (!t) return;
      pointer.moved = pointer.down;
      pointer.dx = 8 * (t.clientX - pointer.x);
      pointer.dy = 8 * (t.clientY - pointer.y);
      pointer.x  = t.clientX;
      pointer.y  = t.clientY;
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('touchmove', onTouch, { passive: true });

    /* ─── bloom pass ────────────────────────────────────────────────── */
    const applyBloom = (source, destination) => {
      if (bloomBuffers.length < 2) return;
      let last = destination;

      gl.disable(gl.BLEND);
      pBloomPre.bind();
      const knee = CFG.BLOOM_THRESHOLD * CFG.BLOOM_SOFT_KNEE + 0.0001;
      const curveX = CFG.BLOOM_THRESHOLD - knee;
      const curveY = 2 * knee;
      const curveZ = 0.25 / knee;
      gl.uniform3f(pBloomPre.uniforms.curve, curveX, curveY, curveZ);
      gl.uniform1f(pBloomPre.uniforms.threshold, CFG.BLOOM_THRESHOLD);
      gl.uniform1i(pBloomPre.uniforms.uTexture, source.attach(0));
      gl.viewport(0, 0, last.width, last.height);
      blit(last.fbo);

      pBloomBlur.bind();
      for (let i = 0; i < bloomBuffers.length; i++) {
        const dst = bloomBuffers[i];
        gl.uniform2f(pBloomBlur.uniforms.texelSize, 1 / last.width, 1 / last.height);
        gl.uniform1i(pBloomBlur.uniforms.uTexture, last.attach(0));
        gl.viewport(0, 0, dst.width, dst.height);
        blit(dst.fbo);
        last = dst;
      }

      gl.blendFunc(gl.ONE, gl.ONE);
      gl.enable(gl.BLEND);
      for (let i = bloomBuffers.length - 2; i >= 0; i--) {
        const dst = bloomBuffers[i];
        gl.uniform2f(pBloomBlur.uniforms.texelSize, 1 / last.width, 1 / last.height);
        gl.uniform1i(pBloomBlur.uniforms.uTexture, last.attach(0));
        gl.viewport(0, 0, dst.width, dst.height);
        blit(dst.fbo);
        last = dst;
      }

      gl.disable(gl.BLEND);
      pBloomFinal.bind();
      gl.uniform2f(pBloomFinal.uniforms.texelSize, 1 / last.width, 1 / last.height);
      gl.uniform1i(pBloomFinal.uniforms.uTexture, last.attach(0));
      gl.uniform1f(pBloomFinal.uniforms.intensity, CFG.BLOOM_INTENSITY);
      gl.viewport(0, 0, destination.width, destination.height);
      blit(destination.fbo);
    };

    /* ─── main loop ─────────────────────────────────────────────────── */
    // Simulation timestep. dt scales how far the velocity field evolves
    // and how far dye is advected each frame, so multiplying by SIM_SPEED
    // is a single knob for "slow motion". 0.1 = 10× slower than realtime.
    const SIM_SPEED = 0.35;
    const dt = 0.016 * SIM_SPEED;
    let raf;

    const render = () => {
      // resize — keep canvas in sync with device pixels
      const wantW = Math.round(canvas.clientWidth  * dpr);
      const wantH = Math.round(canvas.clientHeight * dpr);
      if (canvas.width !== wantW || canvas.height !== wantH) {
        canvas.width  = wantW;
        canvas.height = wantH;
        initFramebuffers();
      }

      // pointer splat — normalize pointer to 0–1 using CSS pixels
      // (clientWidth/Height), not device pixels (canvas.width/.height),
      // since clientX/clientY are reported in CSS coordinates.
      if (pointer.moved) {
        splat(
          pointer.x / canvas.clientWidth,
          1 - pointer.y / canvas.clientHeight,
          pointer.dx,
          pointer.dy,
          pointer.color,
        );
        pointer.moved = false;
      }

      gl.disable(gl.BLEND);

      // curl
      gl.viewport(0, 0, simW, simH);
      pCurlProg.bind();
      gl.uniform2f(pCurlProg.uniforms.texelSize, 1 / simW, 1 / simH);
      gl.uniform1i(pCurlProg.uniforms.uVelocity, velocity.read.attach(0));
      blit(curl_.fbo);

      // vorticity
      pVort.bind();
      gl.uniform2f(pVort.uniforms.texelSize, 1 / simW, 1 / simH);
      gl.uniform1i(pVort.uniforms.uVelocity, velocity.read.attach(0));
      gl.uniform1i(pVort.uniforms.uCurl,     curl_.attach(1));
      gl.uniform1f(pVort.uniforms.curl,      CFG.CURL);
      gl.uniform1f(pVort.uniforms.dt,        dt);
      blit(velocity.write.fbo); velocity.swap();

      // divergence
      pDiv.bind();
      gl.uniform2f(pDiv.uniforms.texelSize, 1 / simW, 1 / simH);
      gl.uniform1i(pDiv.uniforms.uVelocity, velocity.read.attach(0));
      blit(divergence_.fbo);

      // pressure decay
      pCopy.bind();
      gl.uniform1i(pCopy.uniforms.uTexture, pressure.read.attach(0));
      gl.uniform1f(pCopy.uniforms.value,    CFG.PRESSURE_DISSIPATION);
      blit(pressure.write.fbo); pressure.swap();

      // pressure solve
      pPress.bind();
      gl.uniform2f(pPress.uniforms.texelSize,   1 / simW, 1 / simH);
      gl.uniform1i(pPress.uniforms.uDivergence, divergence_.attach(0));
      for (let i = 0; i < CFG.PRESSURE_ITERATIONS; i++) {
        gl.uniform1i(pPress.uniforms.uPressure, pressure.read.attach(1));
        blit(pressure.write.fbo); pressure.swap();
      }

      // gradient subtract
      pGrad.bind();
      gl.uniform2f(pGrad.uniforms.texelSize, 1 / simW, 1 / simH);
      gl.uniform1i(pGrad.uniforms.uPressure, pressure.read.attach(0));
      gl.uniform1i(pGrad.uniforms.uVelocity, velocity.read.attach(1));
      blit(velocity.write.fbo); velocity.swap();

      // advect velocity
      pAdv.bind();
      gl.uniform2f(pAdv.uniforms.texelSize,   1 / simW, 1 / simH);
      const velSlot = velocity.read.attach(0);
      gl.uniform1i(pAdv.uniforms.uVelocity,   velSlot);
      gl.uniform1i(pAdv.uniforms.uSource,     velSlot);
      gl.uniform1f(pAdv.uniforms.dt,          dt);
      gl.uniform1f(pAdv.uniforms.dissipation, CFG.VELOCITY_DISSIPATION);
      blit(velocity.write.fbo); velocity.swap();

      // advect dye
      gl.viewport(0, 0, dyeW, dyeH);
      gl.uniform1i(pAdv.uniforms.uVelocity,   velocity.read.attach(0));
      gl.uniform1i(pAdv.uniforms.uSource,     dye.read.attach(1));
      gl.uniform1f(pAdv.uniforms.dissipation, CFG.DENSITY_DISSIPATION);
      blit(dye.write.fbo); dye.swap();

      // bloom
      if (CFG.BLOOM) applyBloom(dye.read, bloom);

      // display to screen with transparency over the dark page
      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
      gl.enable(gl.BLEND);
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      const useShading = CFG.SHADING && supportLinearFiltering;
      const useBloom   = CFG.BLOOM   && bloomBuffers.length >= 2;
      const display = useShading
        ? (useBloom ? pDisplayBoth    : pDisplayShading)
        : (useBloom ? pDisplayBloom   : pDisplay);

      display.bind();
      gl.uniform1i(display.uniforms.uTexture, dye.read.attach(0));
      if (useBloom)   gl.uniform1i(display.uniforms.uBloom,    bloom.attach(1));
      if (useShading) gl.uniform2f(display.uniforms.texelSize, 1 / gl.drawingBufferWidth, 1 / gl.drawingBufferHeight);
      blit(null);

      raf = requestAnimationFrame(render);
    };
    raf = requestAnimationFrame(render);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('touchmove', onTouch);
    };
  }, []);

  return <canvas ref={ref} id="smoke-sim" />;
};

window.FluidShader = FluidShader;
