/* Volumetric 3D cloud renderer. * Raymarches a horizontal cloud layer with the camera flying * slowly above and through the cloud deck. Density threshold * comes from the weather theme, so 'sunny' gives breaks in the * deck and 'stormy' puts you inside overcast most of the time. * * Reads palette + weather + speed from window globals (same * contract as clouds2d.js, theme-agnostic). */ (function() { function makeShader(gl, type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error('clouds3d: Shader compile error:', gl.getShaderInfoLog(s)); gl.deleteShader(s); return null; } return s; } function makeProgram(gl, vsSrc, fsSrc) { var vs = makeShader(gl, gl.VERTEX_SHADER, vsSrc); var fs = makeShader(gl, gl.FRAGMENT_SHADER, fsSrc); if (!vs || !fs) return null; var p = gl.createProgram(); gl.attachShader(p, vs); gl.attachShader(p, fs); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { console.error('clouds3d: Program link error:', gl.getProgramInfoLog(p)); gl.deleteProgram(p); return null; } return p; } var VERT_SRC = [ 'attribute vec2 aPos;', 'void main() { gl_Position = vec4(aPos, 0.0, 1.0); }' ].join('\n'); // Volumetric raymarch through a horizontal cloud layer. // Cheap 3D value noise + 3-octave fbm. 18 raymarch steps. // Tuned for low-end hardware: fewer steps + fewer noise samples // per step keep the frame within budget on integrated GPUs. // Camera path is sinusoidal so the view occasionally dips // into the cloud deck for the "fly through the edges" feel. var FRAG_SRC = [ 'precision highp float;', 'uniform vec2 uResolution;', 'uniform float uTime;', 'uniform vec3 uSky;', 'uniform vec3 uCloud;', 'uniform vec3 uCloudShadow;', 'uniform vec3 uLight;', 'uniform vec3 uBg;', 'uniform float uSpeed;', 'uniform float uDensity;', 'uniform float uDayProgress;', 'uniform float uSunAlt;', // radians, negative = below horizon (night) // 3D hash and value noise 'float hash3(vec3 p) {', 'p = fract(p * vec3(0.1031, 0.1030, 0.0973));', 'p += dot(p, p.yxz + 33.33);', 'return fract((p.x + p.y) * p.z);', '}', // 2D hash for per-pixel dithering. Adding uTime to the input // means the dither pattern changes each frame so noise doesn't // crawl on slow camera motion. 'float hash21(vec2 p) {', 'p = fract(p * vec2(123.34, 456.21));', 'p += dot(p, p + 45.32);', 'return fract(p.x * p.y);', '}', 'float noise3(vec3 p) {', 'vec3 i = floor(p);', 'vec3 f = fract(p);', 'f = f * f * (3.0 - 2.0 * f);', 'return mix(', 'mix(mix(hash3(i + vec3(0,0,0)), hash3(i + vec3(1,0,0)), f.x),', 'mix(hash3(i + vec3(0,1,0)), hash3(i + vec3(1,1,0)), f.x), f.y),', 'mix(mix(hash3(i + vec3(0,0,1)), hash3(i + vec3(1,0,1)), f.x),', 'mix(hash3(i + vec3(0,1,1)), hash3(i + vec3(1,1,1)), f.x), f.y),', 'f.z);', '}', 'float fbm3(vec3 p) {', 'float v = 0.0;', 'float a = 0.5;', 'for (int i = 0; i < 4; i++) {', 'v += a * noise3(p);', 'p = p * 2.03 + vec3(13.7, 7.1, 19.3);', 'a *= 0.5;', '}', 'return v;', '}', // Density at a point in the cloud layer. Multi-layer fBm: base for // the large puffs, mid layer for secondary structure, two fine // layers for wispy edges. Combined gives richly textured clouds // even at low weather density. 'float cloudDensity(vec3 p, float baseThresh) {', 'float n = fbm3(p * 0.35 + vec3(0.0, 0.0, uTime * 0.06));', 'float d = fbm3(p * 1.4 + vec3(uTime * 0.04, 7.3, uTime * 0.03));', 'float f1 = fbm3(p * 3.2 + vec3(2.1, uTime * 0.02, 5.7));', 'float f2 = fbm3(p * 6.5 + vec3(11.3, 4.7, uTime * 0.05));', 'n = n + d * 0.35 + f1 * 0.18 + f2 * 0.10;', // Cloud layer thickness: between y=-0.3 (deck bottom) // and y=1.5 (top), densest in the middle. 'float h = clamp((p.y + 0.3) / 1.8, 0.0, 1.0);', 'float profile = 4.0 * h * (1.0 - h); // bell curve', 'profile = max(profile, 0.0);', 'return smoothstep(baseThresh, baseThresh + 0.20, n) * profile;', '}', // Star field: project ray onto a far sphere, hash-grid for // positions. Visible above the horizon at night with a small // twinkle. 'float starField(vec3 r) {', 'vec3 sp = r * 220.0;', 'vec3 sf = floor(sp);', 'vec3 sfp = fract(sp) - 0.5;', 'float h = hash3(sf);', 'float starD = length(sfp);', 'float mask = step(0.985, h) * smoothstep(0.35, 0.0, starD);', // Slow, subtle twinkle (0.5 Hz, ±15%). 3 Hz was too fast // and looked like static instead of gentle scintillation. 'float twinkle = 0.85 + 0.15 * sin(uTime * 0.5 + h * 47.0);', 'return mask * twinkle;', '}', 'void main() {', 'vec2 uv = (gl_FragCoord.xy / uResolution.xy) * 2.0 - 1.0;', 'uv.x *= uResolution.x / uResolution.y;', // Camera path: slow drift in X, cruising just above the cloud deck // like an airliner cockpit. No dives — we stay above so the // deck is always visible below us. 'float drift = uTime * uSpeed * 0.45;', // Fixed altitude well above the cloud ceiling (y=1.5). 'float camY = 2.2;', 'vec3 ro = vec3(drift, camY, drift * 0.7);', // Ray direction: nearly level, like looking out a cockpit // window. Horizon sits around the middle of the frame. 'vec3 rd = normalize(vec3(uv.x * 0.55, -0.05 + uv.y * 0.35, 1.0));', // Sky gradient based on ray Y (background). 'float skyT = clamp(rd.y * 0.7 + 0.5, 0.0, 1.0);', 'vec3 col = mix(uBg, uSky, skyT);', // Stars: hash-grid sphere, above horizon, only at night. // Night = sun altitude below 0. Visibility extends well into the // lower half of the sky so the airplane-cockpit view shows stars // from the horizon up. 'float nightFactor = smoothstep(0.05, -0.15, uSunAlt);', 'float skyMask = smoothstep(-0.10, 0.30, rd.y);', 'float stars = starField(rd) * skyMask * nightFactor;', // (nightFactor is also used below for the final darken pass.)', 'col = mix(col, uLight, clamp(stars, 0.0, 1.0) * 0.9);', // Density threshold: weather 'sunny' -> high (sparse clouds), // 'stormy' -> low (overcast deck). 'float baseThresh = mix(0.72, 0.40, uDensity);', // Skip the gap between camera and cloud top so we don't waste // steps marching through clear sky. ray-Y = 1.5 = cloud ceiling. 'float t = (ro.y > 1.5 && rd.y < -0.001) ? (ro.y - 1.5) / -rd.y : 0.0;', // Per-pixel jitter (up to one stepSize) breaks the regular // marching pattern that causes flickering at the cloud horizon. // Re-keyed each frame so the noise does not crawl. 't += hash21(gl_FragCoord.xy + uTime) * 0.85;', 'float stepSize = 0.85;', 'float trans = 1.0;', 'for (int i = 0; i < 18; i++) {', 'vec3 p = ro + rd * t;', 'if (p.y > 1.5 || p.y < -0.4) break;', 'float d = cloudDensity(p, baseThresh);', 'if (d > 0.005) {', // Cloud shading: bottom = shadow, top = lit 'float lit = clamp((p.y + 0.3) / 2.1, 0.0, 1.0);', 'vec3 cloudCol = mix(uCloudShadow, uCloud, lit);', // Sun tint on the lit side, fades at night 'cloudCol = mix(cloudCol, uLight, max(0.15, uDayProgress) * 0.35 * lit);', 'float alpha = clamp(d * stepSize * 0.9, 0.0, 1.0);', 'col = mix(col, cloudCol, alpha * trans);', 'trans *= (1.0 - alpha);', 'if (trans < 0.04) break;', '}', 't += stepSize;', '}', // Night darkening — driven by sun altitude, not uDayProgress // (uDayProgress is the 0..1 clock progress through 24h, which // is not the same as 'is it night'). // Only darkens the sky, NOT the clouds — we want them to stay // moon-lit (their colour came from uLight) while the sky around // them goes dark. transmittance tells us how much light made it // through the cloud layer (1 = clear, 0 = fully cloudy). 'float cloud = 1.0 - trans;', 'float skyDarkness = nightFactor * 0.85;', 'col = mix(col, col * (1.0 - skyDarkness), 1.0 - cloud);', 'gl_FragColor = vec4(col, 1.0);', '}' ].join('\n'); function init(canvas) { var gl = canvas.getContext('webgl', { antialias: false, alpha: false }); if (!gl) { console.error('clouds3d: WebGL not available'); return null; } var prog = makeProgram(gl, VERT_SRC, FRAG_SRC); if (!prog) return null; var buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1,-1, 3,-1, -1,3 ]), gl.STATIC_DRAW); var aPos = gl.getAttribLocation(prog, 'aPos'); var uRes = gl.getUniformLocation(prog, 'uResolution'); var uTime = gl.getUniformLocation(prog, 'uTime'); var uSky = gl.getUniformLocation(prog, 'uSky'); var uCloud = gl.getUniformLocation(prog, 'uCloud'); var uCloudShadow = gl.getUniformLocation(prog, 'uCloudShadow'); var uLight = gl.getUniformLocation(prog, 'uLight'); var uBg = gl.getUniformLocation(prog, 'uBg'); var uSpeed = gl.getUniformLocation(prog, 'uSpeed'); var uDensity = gl.getUniformLocation(prog, 'uDensity'); var uDayProgress = gl.getUniformLocation(prog, 'uDayProgress'); var uSunAlt = gl.getUniformLocation(prog, 'uSunAlt'); return { gl: gl, prog: prog, buf: buf, aPos: aPos, uRes: uRes, uTime: uTime, uSky: uSky, uCloud: uCloud, uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg, uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress, uSunAlt: uSunAlt }; } function hex(rgb) { return [((rgb >> 16) & 255) / 255, ((rgb >> 8) & 255) / 255, (rgb & 255) / 255]; } // Cloud density per weather (0 = clear, 1 = overcast). // Names mirror the weather themes defined in background.html. var WEATHER_DENSITY = { sunny: 0.20, cloudy: 0.70, stormy: 1.00, night: 0.45 }; window.clouds3dRender = function(target) { if (!target || typeof target !== 'object') { console.error('clouds3d: invalid target'); return; } var canvas = target.__clouds3dCanvas; if (!canvas) { canvas = document.createElement('canvas'); canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0'; canvas.style.width = '100%'; canvas.style.height = '100%'; while (target.firstChild) target.removeChild(target.firstChild); target.appendChild(canvas); target.style.position = 'relative'; target.__clouds3dCanvas = canvas; } if (!canvas.__state) { canvas.__state = init(canvas); if (!canvas.__state) return; } var s = canvas.__state; var gl = s.gl; canvas.width = target.clientWidth || window.innerWidth; canvas.height = target.clientHeight || window.innerHeight; gl.viewport(0, 0, canvas.width, canvas.height); gl.useProgram(s.prog); gl.bindBuffer(gl.ARRAY_BUFFER, s.buf); gl.enableVertexAttribArray(s.aPos); gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0); gl.uniform2f(s.uRes, canvas.width, canvas.height); gl.uniform1f(s.uTime, performance.now() / 1000); var pal = window.__currentPalette; if (!pal) { gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return; } var weather = window.__currentWeatherName; var dayProgress = window.__currentDayProgress; var themeSpeed = window.__currentThemeSpeed; gl.uniform3fv(s.uSky, hex(pal.sky)); gl.uniform3fv(s.uCloud, hex(pal.cloud)); gl.uniform3fv(s.uCloudShadow, hex( pal.cloudShadow !== undefined ? pal.cloudShadow : pal.sky)); gl.uniform3fv(s.uLight, hex(pal.light)); gl.uniform3fv(s.uBg, hex(pal.bg)); var density = (weather && WEATHER_DENSITY[weather] !== undefined) ? WEATHER_DENSITY[weather] : 0.5; gl.uniform1f(s.uDensity, density); var day = (typeof dayProgress === 'number') ? Math.max(0, Math.min(1, dayProgress)) : 0.5; gl.uniform1f(s.uDayProgress, day); var sunAlt = (typeof window.__currentSunAlt === 'number') ? window.__currentSunAlt : 0; gl.uniform1f(s.uSunAlt, sunAlt); var speed = (typeof themeSpeed === 'number') ? Math.max(0.1, Math.min(3, themeSpeed)) : 1.0; gl.uniform1f(s.uSpeed, speed); gl.drawArrays(gl.TRIANGLES, 0, 3); }; })();