Files
animated_wallpaper/vendor/clouds3d.js
T
Richard Zink 73920d0ce8 Both axes without swap: UNPACK flip for Y only
User clarified:
- x must decrease left to right (east at screen left, high x left)
- y must decrease in flight direction (south at front, low y in flight dir)

With no swap on canvasY (lng+halfLong+0.5 formula) and UNPACK
flip=true, canvas top -> texture top -> screen top. canvas bottom
-> screen bottom.

So:
- lng=+12 (north, ahead in N flight) -> canvasY=6272 (bottom)
  -> screen bottom. User at screen bottom sees north tiles = high y
- south tiles at canvas top -> screen top. User at screen top sees
  south tiles = low y. y decreases in flight direction (looking
  forward = top of screen = low y).

With no swap on canvasX:
- perp=-2 (east, dx=+2) -> canvasX=128 (left)
- After WebGL (no X flip): east at screen left, west at screen right.
  x decreases left to right (east at left, west at right).
2026-10-08 07:41:14 +02:00

591 lines
27 KiB
JavaScript

/* 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 vec2 uGroundSize;', // mosaic side extents in X and Z (world units)
'uniform vec2 uFlightDir;', // camera motion direction (dx, dz)
'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.12;',
// Fixed altitude well above the cloud ceiling (y=1.5).
'float camY = 2.2;',
// Camera position uses uFlightDir uniform (set from JS each frame).
'vec3 ro = vec3(drift * uFlightDir.x, camY, drift * uFlightDir.y);',
// Ray direction: tilt more downward so the visible ground fits
// within the mosaic. At uv.y = 0 the ray goes ~26° below
// horizontal, putting the visible ground at tGround < 5
// (well inside the 16x16 mosaic at most camera altitudes).
'vec3 rd = normalize(vec3(uv.x * 0.55, -0.45 + uv.y * 0.65, 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;',
// Project hitPoint onto the corridor rotated axes (travel ~35 deg). Then divide by per-axis extent so the corridor maps to UV 0..1. The mosaic canvas is drawn in this rotated frame, so the satellite image comes out oriented correctly.
'vec2 offset = vec2(hitPoint.x, hitPoint.z) - uGroundCenter;',
// rotated.x = world Z (corridor long axis, mapped to canvas Y),
// rotated.y = -world X (corridor perp, mapped to canvas X).
'vec2 rotated = vec2(offset.x * 0.0 + offset.y * 1.0, -offset.x * 1.0 + offset.y * 0.0);',
// Swap components so canvas X = perp direction, canvas Y = long direction
'vec2 gUv = vec2(rotated.y / uGroundSize.x + 0.5, rotated.x / uGroundSize.y + 0.5);',
// Soft fade at UV edges so OOB samples blend to fog instead of producing a hard stripe.
// After swapping gUv axes, perpFade operates on gUv.x and longFade on gUv.y
'float perpFade = smoothstep(-0.08, 0.05, gUv.x) * smoothstep(1.08, 0.95, gUv.x);',
'float longFade = smoothstep(-0.08, 0.05, gUv.y) * smoothstep(1.08, 0.95, gUv.y);',
'float edgeFade = perpFade * longFade;',
'vec3 groundCol = texture2D(uGround, gUv).rgb;',
'float fogAmount = clamp(tGround / 14.0, 0.0, 1.0);',
'groundCol = mix(groundCol, uBg, fogAmount);',
// Edge fade: ground smoothly disappears near canvas boundary.
'groundCol = mix(uBg, groundCol, edgeFade);',
// Ground mask: full ground when looking down, none when up.
'float gmask = (tGround > 0.0) ? smoothstep(0.0, -0.10, 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 uFlightDir = gl.getUniformLocation(prog, 'uFlightDir');
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,
uFlightDir: uFlightDir,
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;
// Fetch direction: which way the tile corridor is oriented.
// Independent of flight direction (set via #fetch=N|NE|E|...).
// Default N (= +Z, canvas Y = +Z).
var GROUND_TRAVEL_COS = (window.__fetchDir && window.__fetchDir.cos) || 0.0;
var GROUND_TRAVEL_SIN = (window.__fetchDir && window.__fetchDir.sin) || 1.0;
var GROUND_CORRIDOR_LONG = 25; // tiles along travel direction (~3.5 min of flight at uSpeed=1)
var GROUND_CORRIDOR_PERP = 5; // tiles perpendicular to travel
var GROUND_BUFFER_LONG = 11; // refetch when this close to forward edge
var GROUND_BUFFER_PERP = 1; // refetch when this close to side edge
// Passau tile at zoom 11
var GROUND_BASE_X = 1100;
var GROUND_BASE_Y = 707;
// 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(lng, perp, dx, dz, centerTileX, centerTileY) {
var tileX = centerTileX + dx;
var tileY = centerTileY + dz;
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 2);
// Canvas position: place each tile so its CENTRE sits at the
// rotated-frame coordinate (lng, perp), but with lng along canvas Y
// (vertical) and perp along canvas X (horizontal). This way the
// corridor's long axis (flight direction +Z) appears as a vertical
// strip on canvas, matching the way depth maps on screen.
// UNPACK_FLIP_Y_WEBGL=true inverts V: canvas top -> texture V=1 (top).
// For lng axis (Y): invert canvasY so lng=+12 (corridor front in
// N flight) lands at small canvasY (= screen top after flip).
// For perp axis (X): keep the natural sign so world east (dx>0)
// ends up at canvasX > halfPerp area (right side of canvas).
var canvasX = (perp + halfPerp + 0.5) * GROUND_TILE_SIZE;
var canvasY = (lng + halfLong + 0.5) * GROUND_TILE_SIZE;
if (canvasX < 0 || canvasX >= groundCanvas.width || canvasY < 0 || canvasY >= groundCanvas.height) {
return Promise.resolve(false);
}
return fetchTileImage(tileX, tileY).then(function(img) {
groundCtx.drawImage(img, canvasX, canvasY);
groundLoadedTiles[tileX + '_' + tileY] = true;
// Debug labels: 'N' at the image's north edge (top of canvas
// tile area after UNPACK_FLIP_Y_WEBGL), 'S' at south edge.
// Tile coords X,Y at the centre so we can verify the canvas
// arrangement matches the world directions.
groundCtx.fillStyle = 'rgba(255,255,255,0.85)';
groundCtx.strokeStyle = 'rgba(0,0,0,0.85)';
groundCtx.lineWidth = 3;
groundCtx.font = 'bold 48px sans-serif';
groundCtx.textAlign = 'center';
// N at canvas top of tile area (= north in image)
groundCtx.strokeText('N', canvasX + 128, canvasY + 56);
groundCtx.fillText('N', canvasX + 128, canvasY + 56);
// S at canvas bottom of tile area
groundCtx.strokeText('S', canvasX + 128, canvasY + 240);
groundCtx.fillText('S', canvasX + 128, canvasY + 240);
// Tile coords
groundCtx.font = 'bold 20px monospace';
groundCtx.strokeText('x=' + tileX + ' y=' + tileY,
canvasX + 128, canvasY + 140);
groundCtx.fillText('x=' + tileX + ' y=' + tileY,
canvasX + 128, canvasY + 140);
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(canvasX, canvasY, 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 halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 2);
// Partial refetch: don't clear the canvas. Tiles already loaded
// (from a previous refetch) stay visible. Only new tiles are
// drawn on top, so fetch activity shows up at the front of the
// corridor (in flight direction) instead of flashing the whole
// canvas. The drawTileAt call hashes by (tileX, tileY) so already-
// loaded tiles are skipped.
var promises = [];
for (var perp = -halfPerp; perp <= halfPerp; perp++) {
for (var lng = -halfLong; lng <= halfLong; lng++) {
// Tile offset from camera in world (tile) coords, rotated
// along the travel direction so the corridor follows the
// camera's actual path, not just the +X axis.
var dx = Math.round(lng * GROUND_TRAVEL_COS - perp * GROUND_TRAVEL_SIN);
var dz = Math.round(lng * GROUND_TRAVEL_SIN + perp * GROUND_TRAVEL_COS);
var tileX = centerTileX + dx;
var tileY = centerTileY + dz;
// Skip tiles already loaded (still drawn at the right canvas
// position from a previous refetch or the initial load).
if (groundLoadedTiles[tileX + '_' + tileY]) continue;
promises.push(drawTileAt(lng, perp, dx, dz, centerTileX, centerTileY));
}
}
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 fdx = (window.__flightDir && window.__flightDir.dx) || 0;
var fdz = (window.__flightDir && window.__flightDir.dz) || 1;
// Camera position in world (X, Z) from the dominant flight direction
var camX = (fdx !== 0) ? cameraWorldX * fdx : 0;
var camZ = (fdz !== 0) ? cameraWorldZ * fdz : 0;
var cameraTileX = GROUND_BASE_X + Math.floor(camX);
var cameraTileY = GROUND_BASE_Y + Math.floor(camZ);
// Project camera offset onto the corridor's rotated axes.
// With GROUND_TRAVEL_COS/SIN = (0, 1) the corridor runs along
// +Z (camera's motion + look direction).
var dx = cameraTileX - groundCenterTile.x;
var dy = cameraTileY - groundCenterTile.y;
var projLong = dx * GROUND_TRAVEL_COS + dy * GROUND_TRAVEL_SIN;
var projPerp = -dx * GROUND_TRAVEL_SIN + dy * GROUND_TRAVEL_COS;
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 2);
// Refetch when camera approaches the corridor's front or sides.
if (projLong > halfLong - GROUND_BUFFER_LONG ||
projLong < -halfLong + GROUND_BUFFER_LONG ||
projPerp > halfPerp - GROUND_BUFFER_PERP ||
projPerp < -halfPerp + GROUND_BUFFER_PERP) {
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_CORRIDOR_PERP;
groundCanvas.height = GROUND_TILE_SIZE * GROUND_CORRIDOR_LONG;
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.uniform2f(s.uGroundSize, GROUND_CORRIDOR_PERP, GROUND_CORRIDOR_LONG);
var flightDir = (window.__flightDir && (window.__flightDir.dx !== undefined))
? window.__flightDir : { dx: 0, dz: 1 };
gl.uniform2f(s.uFlightDir, flightDir.dx, flightDir.dz);
var drift = now * speed * 0.12;
if (performance.now() - groundLastCheck > 1500) {
groundLastCheck = performance.now();
maybeRefetchGround(drift, drift, gl);
}
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
})();