diff --git a/internal/web/static/js/weather-gl.js b/internal/web/static/js/weather-gl.js index da96137..41fd10a 100644 --- a/internal/web/static/js/weather-gl.js +++ b/internal/web/static/js/weather-gl.js @@ -5,10 +5,11 @@ // 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, clouds, moon, 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, storm gloom and lightning flash. +// (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"; @@ -32,10 +33,12 @@ // 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, CLOUD0: 8, CLOUD1: 9, CLOUD2: 10, - MOON: 11, HAIL: 12, BEAM: 13 + LEAF0: 5, LEAF1: 6, LEAF2: 7, CLOUD: 8, + HAIL: 12, BEAM: 13 }; var ATLAS = 1024; var uvRects = new Float32Array(16 * 4); @@ -94,66 +97,6 @@ cx2.restore(); } - // Same silhouettes as the 2D engine so clouds keep their look. - var cloudShapes = [ - [[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18], - [0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]], - [[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16], - [0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]], - [[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16], - [0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]] - ]; - - function bakeCloud(cx2, x, y, shape) { - var w = 320, h = 192; - cx2.save(); - cx2.filter = "blur(16px)"; - cx2.fillStyle = "rgba(255,255,255,1)"; - for (var i = 0; i < shape.length; i++) { - cx2.beginPath(); - cx2.arc(x + shape[i][0] * w, y + shape[i][1] * h, shape[i][2] * w * 0.92, 0, Math.PI * 2); - cx2.fill(); - } - cx2.restore(); - } - - function bakeMoon(cx2, x, y) { - var TAU = Math.PI * 2; - var mr = 53, cx = x + 128, cy = y + 128; - // Halo first, unclipped. - var halo = cx2.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4); - halo.addColorStop(0, "rgba(220,230,255,0.28)"); - halo.addColorStop(1, "rgba(220,230,255,0)"); - cx2.fillStyle = halo; - cx2.beginPath(); - cx2.arc(cx, cy, mr * 2.4, 0, TAU); - cx2.fill(); - cx2.save(); - cx2.beginPath(); - cx2.arc(cx, cy, mr, 0, TAU); - cx2.clip(); - var lx = cx + mr * 0.45, ly = cy - mr * 0.45; - var body = cx2.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35); - body.addColorStop(0, "rgba(249,251,255,0.97)"); - body.addColorStop(0.55, "rgba(214,222,240,0.92)"); - body.addColorStop(1, "rgba(140,151,180,0.85)"); - cx2.fillStyle = body; - cx2.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); - var limb = cx2.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr); - limb.addColorStop(0, "rgba(18,24,46,0)"); - limb.addColorStop(1, "rgba(18,24,46,0.4)"); - cx2.fillStyle = limb; - cx2.fillRect(cx - mr, cy - mr, mr * 2, mr * 2); - // A few faint maria so the disc isn't a plain gradient. - cx2.fillStyle = "rgba(120,132,165,0.30)"; - [[-0.25, -0.1, 0.28], [0.18, 0.22, 0.20], [0.05, -0.32, 0.14], [-0.32, 0.3, 0.12]].forEach(function (m) { - cx2.beginPath(); - cx2.arc(cx + m[0] * mr, cy + m[1] * mr, m[2] * mr, 0, TAU); - cx2.fill(); - }); - cx2.restore(); - } - function bakeAtlas() { var c = document.createElement("canvas"); c.width = ATLAS; c.height = ATLAS; @@ -224,15 +167,8 @@ 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 1: clouds, drawn white and tinted per palette at draw time. - bakeCloud(x, 0, 128, cloudShapes[0]); setRect(SPR.CLOUD0, 0, 128, 320, 192); - bakeCloud(x, 320, 128, cloudShapes[1]); setRect(SPR.CLOUD1, 320, 128, 320, 192); - bakeCloud(x, 640, 128, cloudShapes[2]); setRect(SPR.CLOUD2, 640, 128, 320, 192); - - // Row 2: moon, hailstone, beam. - bakeMoon(x, 0, 320); - setRect(SPR.MOON, 0, 320, 256, 256); - + // 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)"); @@ -274,13 +210,16 @@ "#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, unused + "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;", @@ -288,21 +227,56 @@ " 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_uv = r.xy + (a_corner + 0.5) * r.zw;", + " 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 mediump float;", + "precision highp float;", "uniform sampler2D u_tex;", - "in vec2 v_uv; in float v_alpha; in vec3 v_tint;", + "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 - " o = vec4(t.rgb * v_tint, t.a) * v_alpha;", + " 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"); @@ -315,7 +289,8 @@ ].join("\n"); // The sky shader carries every fullscreen effect behind a mode switch: - // 1 clear day (sun glow + slow rays), 2 clear night glow, 3 fog fbm, + // 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 = [ @@ -358,10 +333,30 @@ " } else if (u_mode == 2) {", " vec2 c = vec2(0.82*aspect, 0.18);", " vec2 p = vec2(uv.x*aspect, uv.y);", - " float d = distance(p, c);", + " 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 glow = exp(-d*d*5.0) * (0.12 + 0.05*breathe);", - " rgb = vec3(0.86,0.90,1.0)*glow; a = glow;", + " 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));", @@ -439,6 +434,7 @@ 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"); @@ -593,7 +589,7 @@ 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.sprite = SPR.CLOUD0 + Math.floor(rand(0, 3)); + p.seed = rand(0, 10); p.bobPhase = rand(0, Math.PI * 2); } else if (kind === "fogband") { p.y = rand(H * 0.3, H); @@ -601,7 +597,7 @@ p.vx = rand(6, 16); p.w = rand(320, 640); p.alpha = rand(0.04, 0.08); - p.sprite = SPR.CLOUD0 + Math.floor(rand(0, 3)); + p.seed = rand(0, 10); } else if (kind === "blossom") { p.vy = rand(60, 120); p.swayAmp = rand(20, 50); @@ -663,26 +659,27 @@ return p; } - // Stars + moon are position-stable; only their twinkle animates. + // 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; - var sy = ((i * 37 + 7) % 100) / 100; + 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 * W, y: sy * 0.7 * H, + x: sx, y: sy, size: 2.2 + (i % 3) * 1.6, ph: i % 7, freq: 0.6 + (i % 5) * 0.15 }); } - if (variant === "clear") { - statics.push({ kind: "moon", x: W * 0.82, y: H * 0.18, size: Math.min(W, H) * 0.5 * 0.16 * 4.8 }); - } } function build() { @@ -745,11 +742,11 @@ // ---- frame -------------------------------------------------------------- var n = 0; // instances written this frame - function push(x, y, sx, sy, rot, alpha, sprite, r, g, b) { + 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] = 0; + 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++; } @@ -811,15 +808,11 @@ n = 0; var i, p, sway, tw, a; - // Stars first so the moon and everything else draws over them. + // Stars first so everything else draws over them. for (i = 0; i < statics.length; i++) { p = statics[i]; - if (p.kind === "star") { - 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); - } else if (p.kind === "moon") { - push(p.x, p.y, p.size, p.size, 0, 1, SPR.MOON, 1, 1, 1); - } + 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. @@ -864,14 +857,14 @@ 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.81, 0.85, 0.93] : [0.59, 0.64, 0.75]; + 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), p.sprite, ct[0], ct[1], ct[2]); + 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, p.sprite, ft[0], ft[1], ft[2]); + 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; @@ -968,6 +961,7 @@ 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);