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