Files
animated_wallpaper/vendor/clouds3d.js
T
Richard Zink 6323beccf9 Add satellite ground texture (ESRI World Imagery, Passau)
Pulled a 5x4 tile mosaic of the Passau region (zoom 10) from
ESRI's free ArcGIS World Imagery service, stitched + resized to
1024x768 JPEG (277 KB). Added to Dockerfile as ground.jpg.

Shader side:
- New uniform uGround (texture unit 1)
- horizonMask: rd.y in [-0.20, -0.02] -> ground fades in from 0
  to 1 as we look further down
- gUv derived from rd.xz so looking forward/down scrolls across
  the satellite image (parallax-like feel)
- Distant ground fades toward uBg via a 'fog' factor so the horizon
  blends naturally with the sky gradient

Loading is async — clouds render before ground texture arrives.
Falls back to a black sample if ground.jpg fails to load.
2026-10-07 20:15:53 +02:00

382 lines
15 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 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: when looking down (rd.y < 0), sample the satellite
// ground texture based on ray direction. Use the horizontal
// component as a UV — the further off-axis, the more the
// texture scrolls. Adds parallax-like feel even though the
// ground is a flat image.
'float gmask = smoothstep(-0.02, -0.20, rd.y);',
'vec2 gUv = vec2(rd.x * 0.5 + 0.5, -rd.y * 0.5 + 0.3);',
'vec3 groundCol = texture2D(uGround, gUv).rgb;',
// Distance fog: distant ground fades toward sky colour.
'float fog = clamp(-rd.y * 1.5, 0.0, 1.0);',
'groundCol = mix(groundCol, uBg, fog * 0.6);',
'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 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, uSky: uSky, uCloud: uCloud,
uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg,
uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress,
uSunAlt: uSunAlt
};
}
// Load an image and upload it as a CLAMP_TO_EDGE wrapped texture
// (ground shouldn't tile — it's a real photo).
function loadImageTexture(gl, src) {
return new Promise(function(resolve, reject) {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
var tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
resolve(tex);
};
img.onerror = function() {
reject(new Error('clouds3d: failed to load image: ' + src));
};
img.src = src;
});
}
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;
// Load the satellite ground texture asynchronously. The
// ground isn't strictly needed for the clouds-only view, so
// frames render normally before it loads — the shader just
// samples a black texture in the meantime.
canvas.__groundReady = false;
loadImageTexture(s.gl, 'ground.jpg').then(function(tex) {
canvas.__ground = tex;
canvas.__groundReady = true;
}).catch(function(err) {
console.warn(err.message);
});
}
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 (noise is on unit 0).
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, canvas.__ground || null);
gl.uniform1i(s.uGround, 1);
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);
};
})();