Files
Pete/internal/web/static/js/weather-gl.js
prosolis 0a723418ff Redo moon and clouds as procedural shaders in the GL engine
The baked atlas sprites (blurred-circle clouds, gradient moon with flat
maria blobs) read as cheap. Clouds are now built per-instance in the
particle fragment shader from fbm density with a flattened base and lit
tops; the moon renders in the sky shader with sphere shading, emboss
relief, maria and a halo. Stars skip the moon disc since the sky pass
draws first.
2026-07-08 00:19:55 -07:00

1029 lines
43 KiB
JavaScript

// 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 };
};
})();