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.
This commit is contained in:
prosolis
2026-07-08 00:19:55 -07:00
parent 9b20040b49
commit 0a723418ff

View File

@@ -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);