// WebGL2 weather engine. All the fill-rate work (soft sprites, glows, fog,
// lightning) happens on the GPU so scrolling and battery stay happy; the CPU
// only nudges a few hundred particle positions per frame and uploads one
// instance buffer. weather.js picks this engine when WebGL2 is available and
// falls back to the Canvas2D engine (weather-2d.js) otherwise.
//
// Shape of the engine: one sprite atlas baked once on an offscreen 2D canvas
// (blossoms, leaves, flakes, hail, light beams), one instanced quad program
// that draws every particle in a single call, and one fullscreen "sky" shader
// for the volumetric-looking stuff: fog, Saharan haze, aurora, sun rays, the
// moon, storm gloom and lightning flash. Clouds are fully procedural in the
// particle fragment shader (fbm density, no baked sprite).
(function () {
  "use strict";

  window.PeteWeatherEngines = window.PeteWeatherEngines || {};

  window.PeteWeatherEngines.webgl2 = function (canvas) {
    var gl = canvas.getContext("webgl2", {
      alpha: true,
      premultipliedAlpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      powerPreference: "low-power"
    });
    if (!gl) return null;

    var root = document.documentElement;
    function rand(a, b) { return a + Math.random() * (b - a); }

    // ---- sprite atlas -------------------------------------------------------
    // Everything is drawn white-on-transparent where possible so a per-instance
    // tint can recolor it (clouds, rain, glows); blossoms and leaves keep baked
    // colors because they mix two hues that a single tint can't reproduce.
    // Index 8 (CLOUD) has no atlas rect: the fragment shader spots it and
    // synthesizes a cumulus procedurally instead of sampling the texture.
    var SPR = {
      GLOW: 0, STREAK: 1, FLAKE: 2, BLOSSOM_P: 3, BLOSSOM_J: 4,
      LEAF0: 5, LEAF1: 6, LEAF2: 7, CLOUD: 8,
      HAIL: 12, BEAM: 13
    };
    var ATLAS = 1024;
    var uvRects = new Float32Array(16 * 4);

    function setRect(idx, x, y, w, h) {
      // 2px inset guards against mip/linear bleed from neighboring sprites.
      uvRects[idx * 4 + 0] = (x + 2) / ATLAS;
      uvRects[idx * 4 + 1] = (y + 2) / ATLAS;
      uvRects[idx * 4 + 2] = (w - 4) / ATLAS;
      uvRects[idx * 4 + 3] = (h - 4) / ATLAS;
    }

    function bakeBlossom(cx2, x, y, petalFill, petalEdge) {
      var s = 56, cx = x + 64, cy = y + 64;
      cx2.save();
      cx2.translate(cx, cy);
      for (var i = 0; i < 5; i++) {
        var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
        var px = Math.cos(a) * s * 0.32;
        var py = Math.sin(a) * s * 0.32;
        cx2.fillStyle = petalFill;
        cx2.strokeStyle = petalEdge;
        cx2.lineWidth = 2;
        cx2.beginPath();
        cx2.ellipse(px, py, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
        cx2.fill();
        cx2.stroke();
      }
      cx2.fillStyle = "hsl(48,90%,68%)";
      cx2.beginPath();
      cx2.arc(0, 0, s * 0.13, 0, Math.PI * 2);
      cx2.fill();
      cx2.restore();
    }

    function bakeLeaf(cx2, x, y, hue, light) {
      var s = 56, cx = x + 64, cy = y + 64;
      cx2.save();
      cx2.translate(cx, cy);
      var g = cx2.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
      g.addColorStop(0, "hsl(" + hue + ",70%," + (light - 10) + "%)");
      g.addColorStop(1, "hsl(" + hue + ",75%," + (light + 10) + "%)");
      cx2.fillStyle = g;
      cx2.beginPath();
      cx2.moveTo(-s * 0.55, 0);
      cx2.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
      cx2.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
      cx2.closePath();
      cx2.fill();
      cx2.strokeStyle = "hsla(" + hue + ",60%," + (light - 22) + "%,0.7)";
      cx2.lineWidth = 2;
      cx2.beginPath();
      cx2.moveTo(-s * 0.5, 0);
      cx2.lineTo(s * 0.5, 0);
      cx2.stroke();
      cx2.restore();
    }

    function bakeAtlas() {
      var c = document.createElement("canvas");
      c.width = ATLAS; c.height = ATLAS;
      var x = c.getContext("2d");

      // Row 0: 128px sprites.
      // GLOW — hot core + wide falloff; doubles as mote, star, dust, splash.
      var g = x.createRadialGradient(64, 64, 0, 64, 64, 62);
      g.addColorStop(0, "rgba(255,255,255,1)");
      g.addColorStop(0.18, "rgba(255,255,255,0.55)");
      g.addColorStop(0.5, "rgba(255,255,255,0.16)");
      g.addColorStop(1, "rgba(255,255,255,0)");
      x.fillStyle = g;
      x.fillRect(0, 0, 128, 128);
      setRect(SPR.GLOW, 0, 0, 128, 128);

      // STREAK — vertical rain line with soft sides and faded tips. Baked on a
      // scratch canvas because destination-in erases the whole surface, which
      // would wipe every sprite already on the atlas.
      var sc = document.createElement("canvas");
      sc.width = 64; sc.height = 128;
      var scx = sc.getContext("2d");
      var sg = scx.createLinearGradient(0, 0, 64, 0);
      sg.addColorStop(0.0, "rgba(255,255,255,0)");
      sg.addColorStop(0.42, "rgba(255,255,255,0.55)");
      sg.addColorStop(0.5, "rgba(255,255,255,1)");
      sg.addColorStop(0.58, "rgba(255,255,255,0.55)");
      sg.addColorStop(1.0, "rgba(255,255,255,0)");
      scx.fillStyle = sg;
      scx.fillRect(0, 0, 64, 128);
      var tip = scx.createLinearGradient(0, 0, 0, 128);
      tip.addColorStop(0, "rgba(0,0,0,0)");
      tip.addColorStop(0.18, "rgba(0,0,0,1)");
      tip.addColorStop(0.9, "rgba(0,0,0,1)");
      tip.addColorStop(1, "rgba(0,0,0,0)");
      scx.globalCompositeOperation = "destination-in";
      scx.fillStyle = tip;
      scx.fillRect(0, 0, 64, 128);
      x.drawImage(sc, 128, 0);
      setRect(SPR.STREAK, 128, 0, 64, 128);

      // FLAKE — six-arm crystal for the bigger snowflakes.
      x.save();
      x.translate(256 + 64, 64);
      x.strokeStyle = "rgba(255,255,255,0.95)";
      x.lineCap = "round";
      for (var i = 0; i < 6; i++) {
        x.save();
        x.rotate((i / 6) * Math.PI * 2);
        x.lineWidth = 6;
        x.beginPath(); x.moveTo(0, 0); x.lineTo(0, -50); x.stroke();
        x.lineWidth = 4;
        x.beginPath(); x.moveTo(0, -30); x.lineTo(11, -41); x.stroke();
        x.beginPath(); x.moveTo(0, -30); x.lineTo(-11, -41); x.stroke();
        x.restore();
      }
      x.fillStyle = "rgba(255,255,255,1)";
      x.beginPath(); x.arc(0, 0, 7, 0, Math.PI * 2); x.fill();
      x.restore();
      setRect(SPR.FLAKE, 256, 0, 128, 128);

      bakeBlossom(x, 384, 0, "hsl(345,70%,83%)", "hsla(340,60%,68%,0.5)");
      setRect(SPR.BLOSSOM_P, 384, 0, 128, 128);
      bakeBlossom(x, 512, 0, "hsl(272,62%,76%)", "hsla(268,55%,58%,0.5)");
      setRect(SPR.BLOSSOM_J, 512, 0, 128, 128);

      bakeLeaf(x, 640, 0, 22, 46); setRect(SPR.LEAF0, 640, 0, 128, 128);
      bakeLeaf(x, 768, 0, 35, 50); setRect(SPR.LEAF1, 768, 0, 128, 128);
      bakeLeaf(x, 896, 0, 46, 44); setRect(SPR.LEAF2, 896, 0, 128, 128);

      // Row 2: hailstone, beam. (Clouds and the moon are procedural now, so
      // row 1 sits empty and the shading below keeps its old coordinates.)
      var hg = x.createRadialGradient(256 + 38, 320 + 38, 4, 256 + 48, 320 + 48, 36);
      hg.addColorStop(0, "rgba(255,255,255,1)");
      hg.addColorStop(0.7, "rgba(216,226,240,0.95)");
      hg.addColorStop(1, "rgba(170,185,210,0.9)");
      x.fillStyle = hg;
      x.beginPath(); x.arc(256 + 48, 320 + 48, 34, 0, Math.PI * 2); x.fill();
      setRect(SPR.HAIL, 256, 320, 96, 96);

      // BEAM — horizontal bar with a bright core; lightning segments, gust
      // streaks and shooting-star tails all stretch this. Scratch canvas for
      // the same destination-in reason as STREAK.
      var bc = document.createElement("canvas");
      bc.width = 128; bc.height = 32;
      var bcx = bc.getContext("2d");
      var bg = bcx.createLinearGradient(0, 0, 0, 32);
      bg.addColorStop(0.0, "rgba(255,255,255,0)");
      bg.addColorStop(0.4, "rgba(255,255,255,0.5)");
      bg.addColorStop(0.5, "rgba(255,255,255,1)");
      bg.addColorStop(0.6, "rgba(255,255,255,0.5)");
      bg.addColorStop(1.0, "rgba(255,255,255,0)");
      bcx.fillStyle = bg;
      bcx.fillRect(0, 0, 128, 32);
      var bfade = bcx.createLinearGradient(0, 0, 128, 0);
      bfade.addColorStop(0, "rgba(0,0,0,0)");
      bfade.addColorStop(0.12, "rgba(0,0,0,1)");
      bfade.addColorStop(0.88, "rgba(0,0,0,1)");
      bfade.addColorStop(1, "rgba(0,0,0,0)");
      bcx.globalCompositeOperation = "destination-in";
      bcx.fillStyle = bfade;
      bcx.fillRect(0, 0, 128, 32);
      x.drawImage(bc, 384, 320);
      setRect(SPR.BEAM, 384, 320, 128, 32);

      return c;
    }

    // ---- GL setup -----------------------------------------------------------
    var PARTICLE_VS = [
      "#version 300 es",
      "layout(location=0) in vec2 a_corner;",
      "layout(location=1) in vec4 a_pos;",   // x, y, sizeX, sizeY (CSS px)
      "layout(location=2) in vec4 a_misc;",  // rot, alpha, spriteIdx, seed
      "layout(location=3) in vec3 a_tint;",
      "uniform vec2 u_res;",
      "uniform vec4 u_uv[16];",
      "out vec2 v_uv;",
      "out vec2 v_local;",
      "out float v_alpha;",
      "out vec3 v_tint;",
      "out float v_kind;",
      "out float v_seed;",
      "void main(){",
      "  float c = cos(a_misc.x), s = sin(a_misc.x);",
      "  vec2 k = a_corner * a_pos.zw;",
      "  vec2 p = a_pos.xy + vec2(k.x*c - k.y*s, k.x*s + k.y*c);",
      "  vec2 clip = (p / u_res) * 2.0 - 1.0;",
      "  gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);",
      "  vec4 r = u_uv[int(a_misc.z + 0.5)];",
      "  v_local = a_corner + 0.5;",
      "  v_uv = r.xy + v_local * r.zw;",
      "  v_alpha = a_misc.y;",
      "  v_tint = a_tint;",
      "  v_kind = a_misc.z;",
      "  v_seed = a_misc.w;",
      "}"
    ].join("\n");

    // Sprite index 8 (CLOUD) skips the atlas and builds a cumulus in-shader:
    // an fbm density field masked by an ellipse with a flattened base, lit
    // from above so tops stay bright and undersides go grey-blue. The seed
    // offsets the noise domain so every cloud has its own shape, and u_t
    // drifts it slowly so the shape churns as it crosses the sky.
    var PARTICLE_FS = [
      "#version 300 es",
      "precision highp float;",
      "uniform sampler2D u_tex;",
      "uniform float u_t;",
      "in vec2 v_uv; in vec2 v_local; in float v_alpha; in vec3 v_tint; in float v_kind; in float v_seed;",
      "out vec4 o;",
      "float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123); }",
      "float vnoise(vec2 p){",
      "  vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);",
      "  float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));",
      "  return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);",
      "}",
      "float fbm(vec2 p){",
      "  float v = 0.0, a = 0.5;",
      "  for (int i = 0; i < 4; i++){ v += a * vnoise(p); p = p*2.03 + vec2(17.0); a *= 0.5; }",
      "  return v;",
      "}",
      "void main(){",
      "  vec4 t = texture(u_tex, v_uv);",           // premultiplied
      "  if (v_kind > 7.5 && v_kind < 8.5) {",
      "    vec2 q = v_local - 0.5;",
      "    q.x *= 1.667;",                           // undo the 320:192 quad stretch
      "    vec2 w = q*3.1 + vec2(v_seed*41.7, v_seed*13.9);",
      "    float n = fbm(w + vec2(u_t*0.018, 0.0));",
      "    float n2 = fbm(w*2.4 + vec2(-u_t*0.011, u_t*0.006) + 19.0);",
      "    float body = n*0.7 + n2*0.3;",
      "    float ry = q.y > 0.0 ? q.y*2.9 : q.y*1.8;",  // flat base, puffy top
      "    float r = length(vec2(q.x*1.1, ry));",
      "    float dens = smoothstep(0.34, 0.80, body*0.95 + (0.74 - r));",
      "    float lgt = clamp(0.62 - q.y*1.6 + (body - 0.5)*0.9, 0.0, 1.0);",
      "    vec3 col = mix(v_tint*vec3(0.50,0.54,0.64), v_tint*1.18, lgt);",
      "    o = vec4(col, 1.0) * (dens * v_alpha);",   // premultiplied
      "  } else {",
      "    o = vec4(t.rgb * v_tint, t.a) * v_alpha;",
      "  }",
      "}"
    ].join("\n");

    var SKY_VS = [
      "#version 300 es",
      "void main(){",
      "  vec2 p[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));",
      "  gl_Position = vec4(p[gl_VertexID], 0., 1.);",
      "}"
    ].join("\n");

    // The sky shader carries every fullscreen effect behind a mode switch:
    // 1 clear day (sun glow + slow rays), 2 clear night (procedural moon:
    // sphere-shaded disc with noise relief, maria and a halo), 3 fog fbm,
    // 4 Saharan haze, 5 aurora curtains, 6 storm gloom. u_flash rides on any
    // mode for the lightning whiteout.
    var SKY_FS = [
      "#version 300 es",
      "precision highp float;",
      "out vec4 o;",
      "uniform vec2 u_res;",
      "uniform float u_t;",
      "uniform int u_mode;",
      "uniform float u_level;",   // intensity multiplier 0.6 / 1.0 / 1.4
      "uniform float u_night;",   // 0 day .. 1 night
      "uniform float u_warm;",    // dawn/dusk warmth
      "uniform float u_flash;",
      "float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123); }",
      "float vnoise(vec2 p){",
      "  vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);",
      "  float a = hash(i), b = hash(i+vec2(1,0)), c = hash(i+vec2(0,1)), d = hash(i+vec2(1,1));",
      "  return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);",
      "}",
      "float fbm(vec2 p){",
      "  float v = 0.0, a = 0.5;",
      "  for (int i = 0; i < 4; i++){ v += a * vnoise(p); p = p*2.03 + vec2(17.0); a *= 0.5; }",
      "  return v;",
      "}",
      "void main(){",
      "  vec2 uv = gl_FragCoord.xy / u_res;",
      "  uv.y = 1.0 - uv.y;",                        // 0 = top of page
      "  float aspect = u_res.x / u_res.y;",
      "  vec3 rgb = vec3(0.0); float a = 0.0;",
      "  if (u_mode == 1) {",
      "    vec2 c = vec2(0.82*aspect, 0.18);",
      "    vec2 p = vec2(uv.x*aspect, uv.y);",
      "    float d = distance(p, c);",
      "    float breathe = 0.5 + 0.5*sin(u_t*0.25);",
      "    float glow = exp(-d*d*5.5) * (0.30 + 0.10*breathe);",
      "    float ang = atan(p.y - c.y, p.x - c.x);",
      "    float rays = pow(0.5 + 0.5*sin(ang*9.0 + u_t*0.06), 3.0) * exp(-d*2.4) * 0.10;",
      "    vec3 col = mix(vec3(1.0,0.88,0.55), vec3(1.0,0.60,0.40), u_warm);",
      "    rgb = col*(glow + rays); a = (glow + rays)*0.95;",
      "  } else if (u_mode == 2) {",
      "    vec2 c = vec2(0.82*aspect, 0.18);",
      "    vec2 p = vec2(uv.x*aspect, uv.y);",
      "    float mrad = 0.085 * min(aspect, 1.0);",   // ~8.5% of the short side
      "    vec2 m = (p - c) / mrad;",
      "    float d = length(m);",
      "    float breathe = 0.5 + 0.5*sin(u_t*0.25);",
      "    float halo = exp(-max(d - 1.0, 0.0) * 2.6) * (0.10 + 0.03*breathe);",
      "    float edge = 1.0 - smoothstep(0.97, 1.0, d);",
      "    vec3 mcol = vec3(0.0);",
      "    if (d < 1.0) {",
      "      float z = sqrt(max(0.0, 1.0 - d*d));",
      "      vec3 nrm = vec3(m, z);",
      "      vec2 sp = m / (z + 0.28);",              // foreshorten detail at the limb
      "      float e1 = fbm(sp*3.4 + 11.0);",
      "      float e2 = fbm(sp*3.4 + 11.0 + vec2(0.05,-0.04));", // resample toward the light
      "      float relief = (e2 - e1) * 1.4;",        // emboss: lit rims, shadowed pits
      "      float alb = 0.72 + relief;",
      "      alb -= smoothstep(0.48, 0.70, fbm(sp*1.3 + 4.0)) * 0.24;", // maria
      "      alb += (fbm(sp*7.0 + 27.0) - 0.5) * 0.10;",               // fine grain
      "      vec3 L = normalize(vec3(0.55,-0.42,0.58));",
      "      float shade = 0.16 + 0.84*smoothstep(-0.15, 0.45, dot(nrm, L));",
      "      shade *= 0.75 + 0.25*z;",                // limb darkening
      "      mcol = vec3(0.92,0.94,1.02) * clamp(alb, 0.0, 1.0) * shade;",
      "    }",
      "    rgb = vec3(0.72,0.78,0.95)*halo*(1.0 - edge) + mcol*edge;",
      "    a = halo*(1.0 - edge) + 0.97*edge;",
      "  } else if (u_mode == 3) {",
      "    vec2 q = vec2(uv.x*aspect, uv.y);",
      "    float n1 = fbm(q*vec2(1.6,2.6) + vec2(u_t*0.020, 0.0));",
      "    float n2 = fbm(q*vec2(2.3,3.4) + vec2(-u_t*0.013, u_t*0.006) + 31.0);",
      "    float dens = smoothstep(0.28, 0.85, n1*0.6 + n2*0.5);",
      "    dens *= (0.35 + 0.75*uv.y);",             // hugs the ground
      "    dens *= (0.40 + 0.42*u_level);",
      "    vec3 col = mix(vec3(0.93,0.92,0.90), vec3(0.42,0.47,0.58), u_night);",
      "    rgb = col*dens; a = dens*0.9;",
      "  } else if (u_mode == 4) {",
      "    vec2 q = vec2(uv.x*aspect, uv.y);",
      "    float n = fbm(q*vec2(2.0,2.8) + vec2(u_t*0.03, u_t*0.004));",
      "    float dens = (0.30 + 0.35*n) * (0.55 + 0.45*(1.0 - uv.y));", // dusty sky, clearer ground
      "    dens *= (0.50 + 0.45*u_level);",
      "    vec3 col = mix(vec3(0.87,0.64,0.36), vec3(0.55,0.40,0.24), u_night);",
      "    rgb = col*dens*0.55; a = dens*0.5;",
      "  } else if (u_mode == 5) {",
      "    vec3 acc = vec3(0.0); float sum = 0.0;",
      "    for (int i = 0; i < 3; i++){",
      "      float fi = float(i);",
      "      float band = fbm(vec2(uv.x*(2.2 + fi*0.7) + u_t*(0.030 + 0.011*fi), fi*7.3 + u_t*0.015));",
      "      float center = 0.10 + fi*0.10 + band*0.22;",
      "      float w = 0.045 + 0.030*band;",
      "      float s = exp(-pow((uv.y - center)/w, 2.0));",
      "      s *= 0.7 + 0.3*sin(uv.x*40.0 + u_t*(1.3 + fi) + fi*9.0);",
      "      vec3 col = mix(vec3(0.15,0.95,0.55), vec3(0.55,0.25,0.95), clamp(fi*0.5 + (uv.y - center)*6.0, 0.0, 1.0));",
      "      acc += col*s; sum += s;",
      "    }",
      "    float k = (0.35 + 0.40*u_level) * mix(0.35, 1.0, u_night);",
      "    rgb = acc*k*0.5; a = min(0.8, sum*k*0.4);",
      "  } else if (u_mode == 6) {",
      "    float dk = (1.0 - uv.y)*0.20 + 0.05;",    // gloom heaviest at the top
      "    vec3 col = mix(vec3(0.35,0.38,0.48), vec3(0.05,0.06,0.10), u_night);",
      "    rgb = col*dk; a = dk;",
      "  }",
      "  if (u_flash > 0.0) { rgb += vec3(0.90,0.94,1.0)*(u_flash*0.45); a = min(1.0, a + u_flash*0.45); }",
      "  o = vec4(rgb, a);",                          // premultiplied
      "}"
    ].join("\n");

    function compile(type, src) {
      var sh = gl.createShader(type);
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
        var msg = gl.getShaderInfoLog(sh);
        gl.deleteShader(sh);
        throw new Error("shader: " + msg);
      }
      return sh;
    }
    function link(vsSrc, fsSrc) {
      var p = gl.createProgram();
      gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc));
      gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc));
      gl.linkProgram(p);
      if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error("link: " + gl.getProgramInfoLog(p));
      return p;
    }

    var MAX_INSTANCES = 900;
    var FLOATS = 11; // pos(4) + misc(4) + tint(3)
    var inst = new Float32Array(MAX_INSTANCES * FLOATS);

    var prog, skyProg, vao, skyVao, instBuf, tex;
    var U = {}, SU = {};

    function initGL() {
      try {
        prog = link(PARTICLE_VS, PARTICLE_FS);
        skyProg = link(SKY_VS, SKY_FS);
      } catch (e) {
        return false;
      }
      U.res = gl.getUniformLocation(prog, "u_res");
      U.uv = gl.getUniformLocation(prog, "u_uv");
      U.tex = gl.getUniformLocation(prog, "u_tex");
      U.t = gl.getUniformLocation(prog, "u_t");
      SU.res = gl.getUniformLocation(skyProg, "u_res");
      SU.t = gl.getUniformLocation(skyProg, "u_t");
      SU.mode = gl.getUniformLocation(skyProg, "u_mode");
      SU.level = gl.getUniformLocation(skyProg, "u_level");
      SU.night = gl.getUniformLocation(skyProg, "u_night");
      SU.warm = gl.getUniformLocation(skyProg, "u_warm");
      SU.flash = gl.getUniformLocation(skyProg, "u_flash");

      vao = gl.createVertexArray();
      gl.bindVertexArray(vao);
      var quad = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, quad);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), gl.STATIC_DRAW);
      gl.enableVertexAttribArray(0);
      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
      instBuf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, instBuf);
      gl.bufferData(gl.ARRAY_BUFFER, inst.byteLength, gl.DYNAMIC_DRAW);
      var stride = FLOATS * 4;
      gl.enableVertexAttribArray(1);
      gl.vertexAttribPointer(1, 4, gl.FLOAT, false, stride, 0);
      gl.vertexAttribDivisor(1, 1);
      gl.enableVertexAttribArray(2);
      gl.vertexAttribPointer(2, 4, gl.FLOAT, false, stride, 16);
      gl.vertexAttribDivisor(2, 1);
      gl.enableVertexAttribArray(3);
      gl.vertexAttribPointer(3, 3, gl.FLOAT, false, stride, 32);
      gl.vertexAttribDivisor(3, 1);
      gl.bindVertexArray(null);

      skyVao = gl.createVertexArray();

      tex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bakeAtlas());
      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
      gl.generateMipmap(gl.TEXTURE_2D);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      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.enable(gl.BLEND);
      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
      gl.clearColor(0, 0, 0, 0);
      return true;
    }
    if (!initGL()) return null;

    // ---- sizing -------------------------------------------------------------
    var W = 0, H = 0, DPR = 1;
    function resize() {
      DPR = Math.min(window.devicePixelRatio || 1, 1.75);
      W = canvas.clientWidth || window.innerWidth;
      H = canvas.clientHeight || window.innerHeight;
      canvas.width = Math.max(1, Math.floor(W * DPR));
      canvas.height = Math.max(1, Math.floor(H * DPR));
      gl.viewport(0, 0, canvas.width, canvas.height);
    }
    resize();
    window.addEventListener("resize", function () {
      resize();
      if (variant) buildStatics();
    });

    // ---- palette ------------------------------------------------------------
    function phaseInfo() {
      var ph = root.dataset.phase || "day";
      if (ph === "night") return { night: 1, warm: 0, isNight: true };
      if (ph === "dawn" || ph === "dusk") return { night: 0.2, warm: 1, isNight: false };
      return { night: 0, warm: 0, isNight: false };
    }

    // ---- wind ---------------------------------------------------------------
    // Shared horizontal wind: a slow ambient sway plus random gust pulses.
    // Every variant that falls or drifts samples this so the whole scene leans
    // together instead of each particle wiggling independently.
    var windParams = { base: 0, amb: 0, gust: 0 };
    var windNow = 0, gustCur = 0, gustTarget = 0, gustTimer = 0;
    function windConfig(v) {
      switch (v) {
        case "rain":   return { base: -70, amb: 25, gust: 60 };
        case "storm":  return { base: -130, amb: 55, gust: 160 };
        case "snow":   return { base: 10, amb: 22, gust: 40 };
        case "leaves": return { base: 25, amb: 40, gust: 90 };
        case "wind":   return { base: 170, amb: 60, gust: 240 };
        case "petals":
        case "jacaranda": return { base: 12, amb: 22, gust: 45 };
        case "haze":   return { base: 45, amb: 15, gust: 30 };
        case "hail":   return { base: -40, amb: 20, gust: 60 };
        default:       return { base: 0, amb: 8, gust: 0 };
      }
    }
    function updateWind(t, dt) {
      gustTimer -= dt;
      if (gustTimer <= 0) {
        gustTarget = rand(-1, 1) * windParams.gust;
        gustTimer = rand(2.5, 8);
      }
      gustCur += (gustTarget - gustCur) * Math.min(1, dt * 0.9);
      var amb = Math.sin(t * 0.13) * 0.6 + Math.sin(t * 0.071 + 2.1) * 0.4;
      windNow = windParams.base + amb * windParams.amb + gustCur;
    }

    // ---- particles ----------------------------------------------------------
    var counts = {
      rain: 170, storm: 220, snow: 210, clouds: 10, fog: 4,
      petals: 60, jacaranda: 66, motes: 80, leaves: 46,
      hail: 70, haze: 110, wind: 60, clear: 0, aurora: 0
    };
    var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };

    var variant = null;
    var intensity = "heavy";
    var particles = [];   // dynamic sim particles
    var statics = [];     // stars / moon, rebuilt on resize or phase flip
    var splashes = [];
    var bolt = null;      // { segs, life }
    var flash = 0, nextBolt = 2;
    var shootTimer = rand(5, 14);
    var builtNight = null;

    function spawn(kind, initial) {
      var p = { kind: kind, z: rand(0.7, 1.3) };
      p.x = rand(0, W);
      p.y = initial ? rand(0, H) : rand(-60, -10);
      if (kind === "rain") {
        p.vy = rand(700, 1100) * p.z;
        p.jitter = rand(-25, 25);
        p.len = rand(11, 20) * p.z;
        p.alpha = rand(0.3, 0.6);
      } else if (kind === "drizzle") {
        p.vy = rand(420, 620) * p.z;
        p.jitter = rand(-20, 20);
        p.len = rand(7, 12) * p.z;
        p.alpha = rand(0.18, 0.35);
      } else if (kind === "snow") {
        p.vy = rand(35, 75) * p.z;
        p.swayAmp = rand(12, 34);
        p.swayFreq = rand(0.3, 0.8);
        p.swayPhase = rand(0, Math.PI * 2);
        p.size = rand(2, 4.6) * p.z;
        p.alpha = rand(0.65, 0.95);
        p.rot = rand(0, Math.PI * 2);
        p.vrot = rand(-0.8, 0.8);
        p.crystal = p.size > 3.4;
      } else if (kind === "cloud") {
        p.far = Math.random() < 0.4;
        p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
        p.x = initial ? rand(0, W) : -rand(160, 420);
        p.vx = (p.far ? rand(4, 9) : rand(10, 24));
        p.size = (p.far ? rand(60, 110) : rand(90, 180)) * p.z;
        p.alpha = p.far ? rand(0.18, 0.30) : rand(0.32, 0.55);
        p.seed = rand(0, 10);
        p.bobPhase = rand(0, Math.PI * 2);
      } else if (kind === "fogband") {
        p.y = rand(H * 0.3, H);
        p.x = initial ? rand(0, W) : -rand(200, 500);
        p.vx = rand(6, 16);
        p.w = rand(320, 640);
        p.alpha = rand(0.04, 0.08);
        p.seed = rand(0, 10);
      } else if (kind === "blossom") {
        p.vy = rand(60, 120);
        p.swayAmp = rand(20, 50);
        p.swayFreq = rand(0.4, 0.9);
        p.swayPhase = rand(0, Math.PI * 2);
        p.rot = rand(0, Math.PI * 2);
        p.vrot = rand(-1.2, 1.2);
        p.size = rand(14, 26) * p.z;
        p.alpha = rand(0.75, 1.0);
        p.tumblePhase = rand(0, Math.PI * 2);
        p.tumbleFreq = rand(0.6, 1.6);
        p.bright = rand(0.88, 1.0);
      } else if (kind === "mote") {
        p.vx = rand(-15, 25);
        p.vy = rand(8, 25);
        p.size = rand(1.2, 2.8) * p.z;
        p.alpha = rand(0.35, 0.7);
        p.twinklePhase = rand(0, Math.PI * 2);
        p.twinkleFreq = rand(0.5, 1.5);
      } else if (kind === "leaf") {
        p.vy = rand(70, 130);
        p.swayAmp = rand(30, 70);
        p.swayFreq = rand(0.3, 0.6);
        p.swayPhase = rand(0, Math.PI * 2);
        p.rot = rand(0, Math.PI * 2);
        p.vrot = rand(-2.4, 2.4);
        p.size = rand(20, 38) * p.z;
        p.alpha = rand(0.8, 1.0);
        p.sprite = SPR.LEAF0 + Math.floor(rand(0, 3));
        p.tumblePhase = rand(0, Math.PI * 2);
        p.tumbleFreq = rand(0.8, 2.0);
        p.bright = rand(0.85, 1.0);
      } else if (kind === "hail") {
        p.vy = rand(480, 780) * p.z;
        p.vx = rand(-20, 20);
        p.size = rand(5, 11) * p.z;
        p.alpha = rand(0.75, 1.0);
        p.rot = rand(0, Math.PI * 2);
        p.vrot = rand(-6, 6);
        p.bounces = 0;
      } else if (kind === "dust") {
        p.vx = rand(20, 70);
        p.vy = rand(-8, 14);
        p.size = rand(1.0, 2.4) * p.z;
        p.alpha = rand(0.15, 0.4);
        p.x = initial ? rand(0, W) : -rand(5, 30);
        p.y = rand(0, H);
        p.twinklePhase = rand(0, Math.PI * 2);
        p.twinkleFreq = rand(0.3, 0.9);
      } else if (kind === "gustline") {
        p.y = rand(0, H);
        p.x = initial ? rand(0, W) : -rand(50, 300);
        p.vx = rand(380, 640);
        p.len = rand(90, 220);
        p.alpha = rand(0.05, 0.12);
        p.wobblePhase = rand(0, Math.PI * 2);
        p.wobbleFreq = rand(1.5, 3.5);
      }
      return p;
    }

    // Stars are position-stable; only their twinkle animates. The moon lives
    // in the sky shader (mode 2), which draws under the particles, so on clear
    // nights we skip stars that would land on the disc.
    function buildStatics() {
      statics = [];
      var info = phaseInfo();
      builtNight = info.isNight;
      if (!info.isNight || (variant !== "clear" && variant !== "aurora")) return;
      var n = variant === "aurora" ? 90 : 70;
      var mx = W * 0.82, my = H * 0.18, mr = 0.085 * Math.min(W, H) * 1.25;
      for (var i = 0; i < n; i++) {
        var sx = ((i * 73 + 11) % 100) / 100 * W;
        var sy = ((i * 37 + 7) % 100) / 100 * 0.7 * H;
        if (variant === "clear" && Math.hypot(sx - mx, sy - my) < mr) continue;
        statics.push({
          kind: "star",
          x: sx, y: sy,
          size: 2.2 + (i % 3) * 1.6,
          ph: i % 7, freq: 0.6 + (i % 5) * 0.15
        });
      }
    }

    function build() {
      particles = [];
      splashes = [];
      bolt = null;
      flash = 0;
      nextBolt = rand(1.5, 4);
      shootTimer = rand(4, 10);
      windParams = windConfig(variant);
      gustCur = 0; gustTarget = 0; gustTimer = 0;
      buildStatics();
      if (!variant) return;
      var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
      var i;
      if (variant === "rain" || variant === "storm") {
        for (i = 0; i < N; i++) particles.push(spawn("rain", true));
      } else if (variant === "snow") {
        for (i = 0; i < N; i++) particles.push(spawn("snow", true));
      } else if (variant === "clouds") {
        for (i = 0; i < N; i++) particles.push(spawn("cloud", true));
      } else if (variant === "fog") {
        for (i = 0; i < N; i++) particles.push(spawn("fogband", true));
      } else if (variant === "petals" || variant === "jacaranda") {
        for (i = 0; i < N; i++) particles.push(spawn("blossom", true));
      } else if (variant === "motes") {
        for (i = 0; i < N; i++) particles.push(spawn("mote", true));
      } else if (variant === "leaves") {
        for (i = 0; i < N; i++) particles.push(spawn("leaf", true));
      } else if (variant === "hail") {
        for (i = 0; i < N; i++) particles.push(spawn("hail", true));
        for (i = 0; i < Math.round(N * 0.9); i++) particles.push(spawn("drizzle", true));
      } else if (variant === "haze") {
        for (i = 0; i < N; i++) particles.push(spawn("dust", true));
      } else if (variant === "wind") {
        for (i = 0; i < N; i++) particles.push(spawn("leaf", true));
        for (i = 0; i < 16; i++) particles.push(spawn("gustline", true));
      }
    }

    // ---- lightning ----------------------------------------------------------
    function makeBolt() {
      var segs = [];
      function grow(x, y, angle, maxY, w, depth) {
        while (y < maxY && segs.length < 60) {
          var a = angle + rand(-0.45, 0.45);
          var l = rand(H * 0.03, H * 0.06);
          var nx = x + Math.sin(a) * l;
          var ny = y + Math.cos(a) * l;
          segs.push({ x1: x, y1: y, x2: nx, y2: ny, w: w });
          x = nx; y = ny;
          if (depth < 2 && Math.random() < 0.14) {
            grow(x, y, a + rand(0.5, 1.1) * (Math.random() < 0.5 ? -1 : 1), y + (maxY - y) * 0.4, w * 0.55, depth + 1);
          }
        }
      }
      grow(rand(W * 0.15, W * 0.85), -10, rand(-0.3, 0.3), rand(H * 0.5, H * 0.85), 3.4, 0);
      return { segs: segs, life: 0.45 };
    }

    // ---- frame --------------------------------------------------------------
    var n = 0; // instances written this frame
    function push(x, y, sx, sy, rot, alpha, sprite, r, g, b, seed) {
      if (n >= MAX_INSTANCES) return;
      var o = n * FLOATS;
      inst[o] = x; inst[o + 1] = y; inst[o + 2] = sx; inst[o + 3] = sy;
      inst[o + 4] = rot; inst[o + 5] = alpha; inst[o + 6] = sprite; inst[o + 7] = seed || 0;
      inst[o + 8] = r; inst[o + 9] = g; inst[o + 10] = b;
      n++;
    }

    function skyMode(info) {
      if (variant === "clear") return info.isNight ? 2 : 1;
      if (variant === "fog") return 3;
      if (variant === "haze") return 4;
      if (variant === "aurora") return 5;
      if (variant === "storm") return 6;
      return 0;
    }

    var last = 0, raf = 0;

    function step(now) {
      raf = requestAnimationFrame(step);
      var dt = Math.min(0.05, (now - last) / 1000) || 0.016;
      last = now;
      var t = now / 1000;
      var info = phaseInfo();
      var night = info.isNight;
      if ((variant === "clear" || variant === "aurora") && builtNight !== night) buildStatics();
      updateWind(t, dt);

      gl.clear(gl.COLOR_BUFFER_BIT);

      // Storm bookkeeping before drawing so the flash lands this frame.
      if (variant === "storm") {
        nextBolt -= dt;
        if (nextBolt <= 0) {
          bolt = makeBolt();
          flash = 1;
          nextBolt = rand(2.5, 7);
        }
        if (flash > 0) flash = Math.max(0, flash - dt * 4);
        if (bolt) {
          bolt.life -= dt;
          if (bolt.life <= 0) bolt = null;
        }
      }

      // Sky pass.
      var mode = skyMode(info);
      if (mode !== 0 || flash > 0) {
        gl.useProgram(skyProg);
        gl.bindVertexArray(skyVao);
        gl.uniform2f(SU.res, canvas.width, canvas.height);
        gl.uniform1f(SU.t, t);
        gl.uniform1i(SU.mode, mode);
        gl.uniform1f(SU.level, mults[intensity] || 1);
        gl.uniform1f(SU.night, info.night);
        gl.uniform1f(SU.warm, info.warm);
        gl.uniform1f(SU.flash, flash);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }

      // Particle pass.
      n = 0;
      var i, p, sway, tw, a;

      // Stars first so everything else draws over them.
      for (i = 0; i < statics.length; i++) {
        p = statics[i];
        tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * p.freq + p.ph));
        push(p.x, p.y, p.size * 2.4, p.size * 2.4, 0, 0.55 * tw, SPR.GLOW, 1, 1, 1);
      }

      // Shooting stars on clear/aurora nights.
      if ((variant === "clear" || variant === "aurora") && night) {
        shootTimer -= dt;
        if (shootTimer <= 0 && !stepShoot.active) {
          stepShoot.active = { x: rand(W * 0.1, W * 0.7), y: rand(H * 0.05, H * 0.3), vx: rand(500, 800), vy: rand(150, 320), life: 0.9 };
          shootTimer = rand(6, 18);
        }
      }
      if (stepShoot.active) stepShoot(dt);

      for (i = 0; i < particles.length; i++) {
        p = particles[i];
        if (p.kind === "rain" || p.kind === "drizzle") {
          var vx = windNow * 1.15 + p.jitter;
          p.x += vx * dt;
          p.y += p.vy * dt;
          if (p.y > H + 20) {
            if (p.kind === "rain" && splashes.length < 40 && Math.random() < 0.35) {
              splashes.push({ x: p.x, y: H - rand(2, 16), life: 0.28, max: 0.28 });
            }
            particles[i] = spawn(p.kind, false);
            particles[i].x = rand(-40, W + 80);
            continue;
          }
          var rot = -Math.atan2(vx, p.vy);
          if (night) push(p.x, p.y, 2.6, p.len * 1.7, rot, p.alpha, SPR.STREAK, 0.78, 0.84, 0.94);
          else push(p.x, p.y, 3.0, p.len * 1.7, rot, Math.min(1, p.alpha + 0.25), SPR.STREAK, 0.24, 0.37, 0.59);
        } else if (p.kind === "snow") {
          p.y += p.vy * dt;
          p.x += windNow * 0.35 * dt;
          p.rot += p.vrot * dt;
          if (p.y > H + 12) { particles[i] = spawn("snow", false); continue; }
          if (p.x > W + 20) p.x -= W + 40;
          if (p.x < -20) p.x += W + 40;
          sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
          var col = night ? [0.92, 0.95, 1] : [0.82, 0.89, 1];
          if (p.crystal) push(p.x + sway, p.y, p.size * 2.4, p.size * 2.4, p.rot, p.alpha, SPR.FLAKE, col[0], col[1], col[2]);
          else push(p.x + sway, p.y, p.size * 3.6, p.size * 3.6, 0, p.alpha, SPR.GLOW, col[0], col[1], col[2]);
        } else if (p.kind === "cloud") {
          p.x += p.vx * dt;
          if (p.x - p.size * 1.3 > W + 60) { particles[i] = spawn("cloud", false); continue; }
          var bob = Math.sin(t * 0.08 + p.bobPhase) * 6;
          var ct = night ? [0.38, 0.42, 0.54] : [0.82, 0.85, 0.92];
          var cw = p.size * 2.6, ch = cw * (192 / 320);
          push(p.x, p.y + bob, cw, ch, 0, p.alpha * (p.far ? 0.85 : 1), SPR.CLOUD, ct[0], ct[1], ct[2], p.seed);
        } else if (p.kind === "fogband") {
          p.x += p.vx * dt;
          if (p.x - p.w / 2 > W + 60) { particles[i] = spawn("fogband", false); continue; }
          var ft = night ? [0.5, 0.55, 0.65] : [0.9, 0.89, 0.87];
          push(p.x, p.y, p.w, p.w * 0.3, 0, p.alpha, SPR.CLOUD, ft[0], ft[1], ft[2], p.seed);
        } else if (p.kind === "blossom") {
          p.y += p.vy * dt;
          p.x += windNow * 0.4 * dt;
          p.rot += p.vrot * dt;
          p.tumblePhase += p.tumbleFreq * dt;
          if (p.y > H + 30) { particles[i] = spawn("blossom", false); continue; }
          if (p.x > W + 30) p.x -= W + 60;
          if (p.x < -30) p.x += W + 60;
          sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
          // Fake a 3D flip by squashing one axis with the tumble phase.
          var squash = 0.35 + 0.65 * Math.abs(Math.sin(p.tumblePhase));
          var spr = variant === "jacaranda" ? SPR.BLOSSOM_J : SPR.BLOSSOM_P;
          push(p.x + sway, p.y, p.size * squash, p.size, p.rot, p.alpha, spr, p.bright, p.bright, p.bright);
        } else if (p.kind === "mote") {
          p.x += p.vx * dt;
          p.y += p.vy * dt;
          if (p.y > H + 10 || p.x < -10 || p.x > W + 10) { particles[i] = spawn("mote", false); continue; }
          tw = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
          if (night) { a = p.alpha * (0.45 + 0.55 * tw); push(p.x, p.y, p.size * 10, p.size * 10, 0, a, SPR.GLOW, 1, 0.94, 0.71); }
          else { a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * tw)); push(p.x, p.y, p.size * 10, p.size * 10, 0, a, SPR.GLOW, 0.51, 0.31, 0.71); }
        } else if (p.kind === "leaf") {
          // Gusts push leaves sideways and briefly hold them up.
          var lift = Math.max(0, windNow - windParams.base) * 0.25;
          p.y += (p.vy - lift) * dt;
          p.x += windNow * 0.8 * dt;
          p.rot += p.vrot * dt * (1 + Math.abs(windNow) / 200);
          p.tumblePhase += p.tumbleFreq * dt;
          if (p.y > H + 40) { particles[i] = spawn("leaf", false); continue; }
          if (p.y < -60) p.y = -40;
          if (p.x > W + 40) { p.x -= W + 80; }
          if (p.x < -40) { p.x += W + 80; }
          sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
          var lsq = 0.3 + 0.7 * Math.abs(Math.sin(p.tumblePhase));
          push(p.x + sway, p.y, p.size, p.size * lsq, p.rot, p.alpha, p.sprite, p.bright, p.bright, p.bright);
        } else if (p.kind === "hail") {
          p.vy += 900 * dt; // gravity keeps bounces honest
          p.x += (p.vx + windNow * 0.5) * dt;
          p.y += p.vy * dt;
          p.rot += p.vrot * dt;
          if (p.y > H - 2 && p.vy > 0) {
            p.y = H - 2;
            p.vy = -p.vy * rand(0.25, 0.4);
            p.vx *= 0.7;
            p.bounces++;
            if (p.bounces > 2 || -p.vy < 60) { particles[i] = spawn("hail", false); continue; }
          }
          push(p.x, p.y, p.size * 1.4, p.size * 1.4, p.rot, p.alpha, SPR.HAIL, 1, 1, 1);
        } else if (p.kind === "dust") {
          p.x += (p.vx + windNow * 0.6) * dt;
          p.y += p.vy * dt;
          if (p.x > W + 30 || p.y < -20 || p.y > H + 20) { particles[i] = spawn("dust", false); continue; }
          tw = 0.6 + 0.4 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
          var dc = night ? [0.75, 0.6, 0.4] : [0.85, 0.62, 0.34];
          push(p.x, p.y, p.size * 8, p.size * 8, 0, p.alpha * tw, SPR.GLOW, dc[0], dc[1], dc[2]);
        } else if (p.kind === "gustline") {
          p.x += p.vx * dt;
          p.y += Math.sin(t * p.wobbleFreq + p.wobblePhase) * 30 * dt;
          if (p.x - p.len > W + 60) { particles[i] = spawn("gustline", false); continue; }
          var gc = night ? [0.85, 0.9, 1] : [0.55, 0.6, 0.7];
          push(p.x, p.y, p.len, 2.2, 0, p.alpha, SPR.BEAM, gc[0], gc[1], gc[2]);
        }
      }

      // Rain splashes: quick flat pops where drops land.
      for (i = splashes.length - 1; i >= 0; i--) {
        var s = splashes[i];
        s.life -= dt;
        if (s.life <= 0) { splashes.splice(i, 1); continue; }
        var k = 1 - s.life / s.max;
        var sc = night ? [0.8, 0.86, 0.95] : [0.35, 0.48, 0.68];
        push(s.x, s.y, 6 + k * 16, 2 + k * 3, 0, (1 - k) * 0.4, SPR.GLOW, sc[0], sc[1], sc[2]);
      }

      // Lightning bolt: wide soft glow underneath, hot core on top.
      if (bolt) {
        var ba = (bolt.life > 0.32 ? 1 : bolt.life / 0.32) * (0.75 + 0.25 * Math.sin(t * 80));
        for (i = 0; i < bolt.segs.length; i++) {
          var sg2 = bolt.segs[i];
          var dx = sg2.x2 - sg2.x1, dy = sg2.y2 - sg2.y1;
          var len = Math.sqrt(dx * dx + dy * dy);
          var mx = (sg2.x1 + sg2.x2) / 2, my = (sg2.y1 + sg2.y2) / 2;
          var brot = Math.atan2(dy, dx);
          push(mx, my, len * 1.3, sg2.w * 9, brot, ba * 0.16, SPR.BEAM, 0.6, 0.7, 1);
        }
        for (i = 0; i < bolt.segs.length; i++) {
          var sg3 = bolt.segs[i];
          var dx2 = sg3.x2 - sg3.x1, dy2 = sg3.y2 - sg3.y1;
          var len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
          push((sg3.x1 + sg3.x2) / 2, (sg3.y1 + sg3.y2) / 2, len2 * 1.15, sg3.w, Math.atan2(dy2, dx2), ba, SPR.BEAM, 1, 1, 1);
        }
      }

      if (n > 0) {
        gl.useProgram(prog);
        gl.bindVertexArray(vao);
        gl.uniform2f(U.res, W, H);
        gl.uniform1f(U.t, t);
        gl.uniform4fv(U.uv, uvRects);
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, tex);
        gl.uniform1i(U.tex, 0);
        gl.bindBuffer(gl.ARRAY_BUFFER, instBuf);
        gl.bufferSubData(gl.ARRAY_BUFFER, 0, inst, 0, n * FLOATS);
        gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n);
      }
      gl.bindVertexArray(null);
    }

    // Shooting star renderer/updater hangs its state off the function itself so
    // the main loop stays flat.
    function stepShoot(dt) {
      var sh = stepShoot.active;
      sh.life -= dt;
      if (sh.life <= 0) { stepShoot.active = null; return; }
      sh.x += sh.vx * dt;
      sh.y += sh.vy * dt;
      var a = Math.min(1, sh.life * 2.5) * 0.85;
      var ang = Math.atan2(sh.vy, sh.vx);
      var tail = 90;
      push(sh.x - Math.cos(ang) * tail / 2, sh.y - Math.sin(ang) * tail / 2, tail, 2.4, ang, a * 0.7, SPR.BEAM, 1, 1, 1);
      push(sh.x, sh.y, 14, 14, 0, a, SPR.GLOW, 1, 1, 1);
    }
    stepShoot.active = null;

    // ---- lifecycle ----------------------------------------------------------
    var running = false;
    function start() {
      if (running) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(step);
    }
    function stop() {
      running = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      gl.clear(gl.COLOR_BUFFER_BIT);
    }
    function set(v, inten) {
      variant = v || null;
      intensity = inten || "heavy";
      build();
      if (!variant) stop();
    }

    canvas.addEventListener("webglcontextlost", function (e) {
      e.preventDefault();
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    });
    canvas.addEventListener("webglcontextrestored", function () {
      if (initGL()) {
        resize();
        build();
        if (running) { running = false; start(); }
      }
    });

    return { name: "webgl2", set: set, start: start, stop: stop };
  };
})();
