/* 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 sampler2D uGround;', 'uniform vec2 uGroundCenter;', // current mosaic centre (world units) 'uniform float uGroundSize;', // mosaic side length (world units) '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 < 3; 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. Two fBm layers: the // base one at low frequency gives the large puffs, the detail // layer at higher frequency breaks up the silhouette so clouds // don't look like soft blobs. '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));', 'n = n + d * 0.20;', // 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);', // Widened sky band so stars appear close to the horizon too. 'float skyMask = smoothstep(-0.20, 0.40, 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));', // 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;', // 0.55 — strong but not pitch black, leaves room for stars to // remain visible against a deep but not void-black sky. 'float skyDarkness = nightFactor * 0.55;', 'col = mix(col, col * (1.0 - skyDarkness), 1.0 - cloud);', '// Horizon: cast the ray onto the ground plane (y=0) and sample the satellite image at that world (x,z) coordinate. As the camera drifts forward over time the texture scrolls past, giving the illusion of flying over real terrain. Distant ground fades toward sky colour (atmospheric perspective).', 'float tGround = (ro.y > 0.0 && rd.y < -0.001) ? -ro.y / rd.y : -1.0;', 'vec3 hitPoint = ro + rd * tGround;', // UV from world (hitPoint - centre) / size + 0.5 — the // mosaic moves with the camera so the centre is always at 0.5 'vec2 gUv = vec2(hitPoint.x, hitPoint.z);', 'gUv = (gUv - uGroundCenter) / uGroundSize + 0.5;', 'vec3 groundCol = texture2D(uGround, gUv).rgb;', 'float fogAmount = clamp(tGround / 12.0, 0.0, 1.0);', 'groundCol = mix(groundCol, uBg, fogAmount);', // Ground mask: full ground when looking down, none when up. 'float gmask = (tGround > 0.0) ? smoothstep(0.0, -0.30, rd.y) : 0.0;', 'col = mix(col, groundCol, gmask);', '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 uGround = gl.getUniformLocation(prog, 'uGround'); var uGroundCenter = gl.getUniformLocation(prog, 'uGroundCenter'); var uGroundSize = gl.getUniformLocation(prog, 'uGroundSize'); 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, uGround: uGround, uGroundCenter: uGroundCenter, uGroundSize: uGroundSize, uSky: uSky, uCloud: uCloud, uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg, uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress, uSunAlt: uSunAlt }; } // ---- Ground: tile fetching from ESRI World Imagery --------------------- // At startup we fetch a 9x9 tile mosaic of the Passau region and // upload it as one 2304x2304 WebGL texture. As the camera moves // across the tile grid the system detects when it's near the edge // of the loaded mosaic and re-fetches the missing tiles, shifting // the mosaic centre to follow the camera. // // 1 world unit = 1 tile at zoom 11 (~13 km). The shader maps // (hitPoint - uGroundCenter) / uGroundSize + 0.5 to UV, so the // mosaic centre always corresponds to UV (0.5, 0.5). var GROUND_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/'; var GROUND_TILE_Z = 11; var GROUND_TILE_SIZE = 256; var GROUND_MOSAIC_SIZE = 16; // 16x16 tiles around the camera var GROUND_BUFFER_TILES = 2; // refetch when this close to edge // Passau tile at zoom 11 var GROUND_BASE_X = 1102; var GROUND_BASE_Y = 710; // Module-level state, shared across renderer instances var groundCanvas = null; var groundCtx = null; var groundTexture = null; var groundGl = null; // WebGL context, set by initGroundSystem var groundCenterTile = { x: GROUND_BASE_X, y: GROUND_BASE_Y }; var groundLoadedTiles = {}; // "x_y" -> true var groundLastCheck = 0; var groundUpdatePending = false; function fetchTileImage(tileX, tileY) { return new Promise(function(resolve, reject) { var img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function() { resolve(img); }; img.onerror = function() { reject(new Error('tile fetch failed')); }; img.src = GROUND_TILE_URL + GROUND_TILE_Z + '/' + tileY + '/' + tileX; }); } function drawTileAt(tileX, tileY, centerTile) { var r = Math.floor(GROUND_MOSAIC_SIZE / 2); var dx = (tileX - centerTile.x + r) * GROUND_TILE_SIZE; var dy = (tileY - centerTile.y + r) * GROUND_TILE_SIZE; if (dx < 0 || dx >= groundCanvas.width || dy < 0 || dy >= groundCanvas.height) { return Promise.resolve(false); } return fetchTileImage(tileX, tileY).then(function(img) { groundCtx.drawImage(img, dx, dy); groundLoadedTiles[tileX + '_' + tileY] = true; return true; }).catch(function() { // On failure draw a neutral gray so the gap is at least // not the bright fog colour groundCtx.fillStyle = '#3a4458'; groundCtx.fillRect(dx, dy, GROUND_TILE_SIZE, GROUND_TILE_SIZE); return false; }); } // Rebuild the mosaic centred on the given tile. Clears the canvas // and loads every tile in the new range. function rebuildGroundMosaic(centerTileX, centerTileY) { groundCenterTile = { x: centerTileX, y: centerTileY }; var r = Math.floor(GROUND_MOSAIC_SIZE / 2); // Clear with fog colour so the texture is never undefined groundCtx.fillStyle = '#7c9aa1'; groundCtx.fillRect(0, 0, groundCanvas.width, groundCanvas.height); var promises = []; for (var x = centerTileX - r; x <= centerTileX + r; x++) { for (var y = centerTileY - r; y <= centerTileY + r; y++) { promises.push(drawTileAt(x, y, groundCenterTile)); } } return Promise.all(promises).then(function() { if (groundTexture && groundGl) { groundGl.bindTexture(groundGl.TEXTURE_2D, groundTexture); groundGl.texImage2D(groundGl.TEXTURE_2D, 0, groundGl.RGBA, groundGl.RGBA, groundGl.UNSIGNED_BYTE, groundCanvas); } }); } // Called periodically from the render loop with the camera world // position. Re-centres the mosaic if we're approaching its edge. function maybeRefetchGround(cameraWorldX, cameraWorldZ, gl) { var cameraTileX = GROUND_BASE_X + Math.floor(cameraWorldX); var cameraTileY = GROUND_BASE_Y + Math.floor(cameraWorldZ); var r = Math.floor(GROUND_MOSAIC_SIZE / 2); var dx = Math.abs(cameraTileX - groundCenterTile.x); var dy = Math.abs(cameraTileY - groundCenterTile.y); if (dx > r - GROUND_BUFFER_TILES || dy > r - GROUND_BUFFER_TILES) { if (groundUpdatePending) return; groundUpdatePending = true; rebuildGroundMosaic(cameraTileX, cameraTileY).then(function() { groundUpdatePending = false; }); } } // Initialise the ground state. Must be called once with the WebGL // context before clouds3dRender uses it. function initGroundSystem(glCtx) { if (groundCanvas) return; // already initialised groundGl = glCtx; groundCanvas = document.createElement('canvas'); groundCanvas.width = GROUND_TILE_SIZE * GROUND_MOSAIC_SIZE; groundCanvas.height = GROUND_TILE_SIZE * GROUND_MOSAIC_SIZE; groundCtx = groundCanvas.getContext('2d'); // Pre-fill with fog colour so partially-loaded mosaic still // shows the right base colour groundCtx.fillStyle = '#7c9aa1'; groundCtx.fillRect(0, 0, groundCanvas.width, groundCanvas.height); groundTexture = groundGl.createTexture(); groundGl.bindTexture(groundGl.TEXTURE_2D, groundTexture); groundGl.pixelStorei(groundGl.UNPACK_FLIP_Y_WEBGL, true); groundGl.texImage2D(groundGl.TEXTURE_2D, 0, groundGl.RGBA, groundGl.RGBA, groundGl.UNSIGNED_BYTE, groundCanvas); groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_WRAP_S, groundGl.CLAMP_TO_EDGE); groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_WRAP_T, groundGl.CLAMP_TO_EDGE); groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_MIN_FILTER, groundGl.LINEAR); groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_MAG_FILTER, groundGl.LINEAR); // Kick off initial fetch rebuildGroundMosaic(GROUND_BASE_X, GROUND_BASE_Y); } 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; // Initialise the tile-based ground system. This sets up the // canvas + GPU texture and kicks off the initial 9x9 tile // fetch. Frames render normally before tiles arrive — they // sample the fog-coloured initial fill. initGroundSystem(canvas.__state.gl); } 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); // Bind ground texture to texture unit 1. gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, groundTexture); gl.uniform1i(s.uGround, 1); gl.uniform2f(s.uRes, canvas.width, canvas.height); var now = performance.now() / 1000; gl.uniform1f(s.uTime, now); 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); // Ground mosaic uniforms + periodic re-fetch trigger. // uGroundCenter is the world offset of the mosaic centre from // the initial position. uGroundSize is the mosaic side length // in world units (= GROUND_MOSAIC_SIZE since 1 unit = 1 tile). var centerWorld = { x: groundCenterTile.x - GROUND_BASE_X, y: groundCenterTile.y - GROUND_BASE_Y }; gl.uniform2f(s.uGroundCenter, centerWorld.x, centerWorld.y); gl.uniform1f(s.uGroundSize, GROUND_MOSAIC_SIZE); var drift = now * speed * 0.45; if (performance.now() - groundLastCheck > 1500) { groundLastCheck = performance.now(); maybeRefetchGround(drift, drift * 0.7, gl); } gl.drawArrays(gl.TRIANGLES, 0, 3); }; })();