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.
This commit is contained in:
Richard Zink committed 2026-10-07 20:15:53 +02:00
1 parent 2e745c26ad
commit 6323beccf9
4 files changed
+56 -1

No files matched your search

+55 -1
View File
@@ -53,6 +53,7 @@ var FRAG_SRC = [
'precision highp float;',
'uniform vec2 uResolution;',
'uniform float uTime;',
'uniform sampler2D uGround;',
'uniform vec3 uSky;',
'uniform vec3 uCloud;',
'uniform vec3 uCloudShadow;',
@@ -194,6 +195,18 @@ var FRAG_SRC = [
// 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');
@@ -216,6 +229,7 @@ function init(canvas) {
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');
@@ -228,13 +242,37 @@ function init(canvas) {
return {
gl: gl, prog: prog, buf: buf, aPos: aPos,
uRes: uRes, uTime: uTime, uSky: uSky, uCloud: uCloud,
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,
@@ -272,6 +310,17 @@ window.clouds3dRender = function(target) {
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;
@@ -285,6 +334,11 @@ window.clouds3dRender = function(target) {
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);