Runtime tile fetching for ground texture (ESRI)

Replace static ground.jpg with a dynamic tile-based system that
fetches new tiles from ESRI World Imagery as the camera moves
through the world.

- GROUND_TILE_Z = 11 (~13 km per tile)
- GROUND_MOSAIC_SIZE = 9 (9x9 = 81 tiles, 2304x2304 canvas)
- GROUND_BUFFER_TILES = 2 (refetch when within 2 tiles of edge)
- Passau tile at zoom 11: x=1102, y=710

Shader:
- Two new uniforms: uGroundCenter (mosaic centre in world units),
  uGroundSize (mosaic side length = 9)
- gUv = (hitPoint - uGroundCenter) / uGroundSize + 0.5 so the
  mosaic centre stays at UV (0.5, 0.5) as the camera drifts

Runtime:
- maybeRefetchGround() called every 1.5s with current camera
  position; if camera is in the buffer zone, mosaic centre is
  shifted and missing tiles re-fetched in parallel
- Canvas re-uploaded to GPU after each rebuild

Camera can now roam indefinitely — every time we approach the
mosaic edge, a fresh 9x9 of tiles is fetched.
This commit is contained in:
Richard Zink committed 2026-10-07 20:48:25 +02:00
1 parent 052bbfbf49
commit 979134102d
1 file changed
+152 -34
+152 -34
View File
@@ -54,6 +54,8 @@ var FRAG_SRC = [
'uniform vec2 uResolution;', 'uniform vec2 uResolution;',
'uniform float uTime;', 'uniform float uTime;',
'uniform sampler2D uGround;', 'uniform sampler2D uGround;',
'uniform vec2 uGroundCenter;', // current mosaic centre (world units)
'uniform float uGroundSize;', // mosaic side length (world units)
'uniform vec3 uSky;', 'uniform vec3 uSky;',
'uniform vec3 uCloud;', 'uniform vec3 uCloud;',
'uniform vec3 uCloudShadow;', 'uniform vec3 uCloudShadow;',
@@ -198,7 +200,10 @@ var FRAG_SRC = [
'// 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).', '// 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;', 'float tGround = (ro.y > 0.0 && rd.y < -0.001) ? -ro.y / rd.y : -1.0;',
'vec3 hitPoint = ro + rd * tGround;', 'vec3 hitPoint = ro + rd * tGround;',
'vec2 gUv = vec2(hitPoint.x, hitPoint.z) * 0.04 + 0.5;', // 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;', 'vec3 groundCol = texture2D(uGround, gUv).rgb;',
'float fogAmount = clamp(tGround / 30.0, 0.0, 1.0);', 'float fogAmount = clamp(tGround / 30.0, 0.0, 1.0);',
'groundCol = mix(groundCol, uBg, fogAmount * 0.75);', 'groundCol = mix(groundCol, uBg, fogAmount * 0.75);',
@@ -228,6 +233,8 @@ function init(canvas) {
var uRes = gl.getUniformLocation(prog, 'uResolution'); var uRes = gl.getUniformLocation(prog, 'uResolution');
var uTime = gl.getUniformLocation(prog, 'uTime'); var uTime = gl.getUniformLocation(prog, 'uTime');
var uGround = gl.getUniformLocation(prog, 'uGround'); 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 uSky = gl.getUniformLocation(prog, 'uSky');
var uCloud = gl.getUniformLocation(prog, 'uCloud'); var uCloud = gl.getUniformLocation(prog, 'uCloud');
var uCloudShadow = gl.getUniformLocation(prog, 'uCloudShadow'); var uCloudShadow = gl.getUniformLocation(prog, 'uCloudShadow');
@@ -240,37 +247,136 @@ function init(canvas) {
return { return {
gl: gl, prog: prog, buf: buf, aPos: aPos, gl: gl, prog: prog, buf: buf, aPos: aPos,
uRes: uRes, uTime: uTime, uGround: uGround, uSky: uSky, uCloud: uCloud, uRes: uRes, uTime: uTime, uGround: uGround,
uGroundCenter: uGroundCenter, uGroundSize: uGroundSize,
uSky: uSky, uCloud: uCloud,
uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg, uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg,
uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress, uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress,
uSunAlt: uSunAlt uSunAlt: uSunAlt
}; };
} }
// Load an image and upload it as a CLAMP_TO_EDGE wrapped texture // ---- Ground: tile fetching from ESRI World Imagery ---------------------
// (ground shouldn't tile — it's a real photo). // At startup we fetch a 9x9 tile mosaic of the Passau region and
function loadImageTexture(gl, src) { // 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 = 9; // 9x9 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 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) { return new Promise(function(resolve, reject) {
var img = new Image(); var img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
img.onload = function() { img.onload = function() { resolve(img); };
var tex = gl.createTexture(); img.onerror = function() { reject(new Error('tile fetch failed')); };
gl.bindTexture(gl.TEXTURE_2D, tex); img.src = GROUND_TILE_URL + GROUND_TILE_Z + '/' + tileY + '/' + tileX;
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 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) {
gl.bindTexture(gl.TEXTURE_2D, groundTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.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(gl) {
if (groundCanvas) return; // already initialised
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 = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, groundTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, groundCanvas);
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);
// Kick off initial fetch
rebuildGroundMosaic(GROUND_BASE_X, GROUND_BASE_Y);
}
function hex(rgb) { function hex(rgb) {
return [((rgb >> 16) & 255) / 255, return [((rgb >> 16) & 255) / 255,
((rgb >> 8) & 255) / 255, ((rgb >> 8) & 255) / 255,
@@ -308,17 +414,11 @@ window.clouds3dRender = function(target) {
if (!canvas.__state) { if (!canvas.__state) {
canvas.__state = init(canvas); canvas.__state = init(canvas);
if (!canvas.__state) return; if (!canvas.__state) return;
// Load the satellite ground texture asynchronously. The // Initialise the tile-based ground system. This sets up the
// ground isn't strictly needed for the clouds-only view, so // canvas + GPU texture and kicks off the initial 9x9 tile
// frames render normally before it loads — the shader just // fetch. Frames render normally before tiles arrive — they
// samples a black texture in the meantime. // sample the fog-coloured initial fill.
canvas.__groundReady = false; initGroundSystem(canvas.__state.gl);
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 s = canvas.__state;
@@ -332,13 +432,14 @@ window.clouds3dRender = function(target) {
gl.enableVertexAttribArray(s.aPos); gl.enableVertexAttribArray(s.aPos);
gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0);
// Bind ground texture to texture unit 1 (noise is on unit 0). // Bind ground texture to texture unit 1.
gl.activeTexture(gl.TEXTURE1); gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, canvas.__ground || null); gl.bindTexture(gl.TEXTURE_2D, groundTexture);
gl.uniform1i(s.uGround, 1); gl.uniform1i(s.uGround, 1);
gl.uniform2f(s.uRes, canvas.width, canvas.height); gl.uniform2f(s.uRes, canvas.width, canvas.height);
gl.uniform1f(s.uTime, performance.now() / 1000); var now = performance.now() / 1000;
gl.uniform1f(s.uTime, now);
var pal = window.__currentPalette; var pal = window.__currentPalette;
if (!pal) { if (!pal) {
@@ -374,6 +475,23 @@ window.clouds3dRender = function(target) {
? Math.max(0.1, Math.min(3, themeSpeed)) : 1.0; ? Math.max(0.1, Math.min(3, themeSpeed)) : 1.0;
gl.uniform1f(s.uSpeed, speed); 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); gl.drawArrays(gl.TRIANGLES, 0, 3);
}; };