Files
animated_wallpaper/vendor/clouds3d.js
T
Richard Zink 2ad51141c6 Ground now uses real ray cast onto y=0 plane
Instead of mapping rd.x/rd.y to UV directly (which gave a flat
orthographic look), compute where the ray actually intersects
the ground plane and sample the satellite texture at that world
(x,z) coordinate. As the camera drifts forward (uTime), the
ground scrolls, giving the illusion of flight over real terrain.

- tGround = -ro.y / rd.y (with rd.y < 0 guard)
- gUv = (hitPoint.x, hitPoint.z) * 0.04 + 0.5
- Atmospheric perspective: ground fades toward sky with distance
  (fog starts at hit-distance 0, full at 30 world units)
- gmask 1.0 only when tGround > 0 (camera above ground AND ray
  pointing down), 0 otherwise
2026-10-07 20:40:46 +02:00

385 lines
16 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: 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;',
'vec2 gUv = vec2(hitPoint.x, hitPoint.z) * 0.04 + 0.5;',
'vec3 groundCol = texture2D(uGround, gUv).rgb;',
'float fogAmount = clamp(tGround / 30.0, 0.0, 1.0);',
'groundCol = mix(groundCol, uBg, fogAmount * 0.75);',
// 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 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(canvas.__state.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);
};
})();