Rotate corridor along travel direction (35 deg)

Camera moves at uTime * uSpeed * 0.45 in +X with +X*0.7 in +Z.
Previous axis-aligned corridor (long in X, perp in Z) was
perpendicular to the actual travel direction, wasting tiles in
the +Z / -X corner where the camera never goes.

Now the corridor is rotated 35 deg to match travel:
- GROUND_TRAVEL_COS/SIN = 0.82/0.57 (cos/sin of 35 deg)
- GROUND_CORRIDOR_LONG = 15 (tiles along travel)
- GROUND_CORRIDOR_PERP = 5 (tiles perpendicular)
- rebuildGroundMosaic iterates (lng, perp) in rotated frame,
  draws each tile at canvas position (lng, perp) but loads from
  tile (dx, dz) rotated from (lng, perp) by travel angle
- Shader rotates hitPoint into corridor frame, then divides by
  per-axis extent to get UV
This commit is contained in:
Richard Zink committed 2026-10-07 21:06:06 +02:00
1 parent 47f2c16eba
commit 930fd2b2f1
1 file changed
+48 -29
+48 -29
View File
@@ -202,9 +202,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).',
'float tGround = (ro.y > 0.0 && rd.y < -0.001) ? -ro.y / rd.y : -1.0;',
'vec3 hitPoint = ro + rd * tGround;',
'// 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;',
// 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;',
'vec2 rotated = vec2(offset.x * 0.82 + offset.y * 0.57, -offset.x * 0.57 + offset.y * 0.82);',
'vec2 gUv = rotated / uGroundSize + 0.5;',
'vec3 groundCol = texture2D(uGround, gUv).rgb;',
'float fogAmount = clamp(tGround / 14.0, 0.0, 1.0);',
'groundCol = mix(groundCol, uBg, fogAmount);',
@@ -271,12 +272,15 @@ var GROUND_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/Worl
var GROUND_TILE_Z = 11;
var GROUND_TILE_SIZE = 256;
// Camera travels at uTime * uSpeed * 0.45 in +X (and +X*0.7 in +Z).
// A corridor elongated in X gives more forward coverage per tile.
// 13 tiles along X (≈13s of travel) x 5 tiles wide in Z = 65 tiles.
var GROUND_CORRIDOR_X = 13; // tiles along travel direction
var GROUND_CORRIDOR_Z = 5; // tiles perpendicular
var GROUND_BUFFER_X = 2; // refetch when this close to forward edge
var GROUND_BUFFER_Z = 1; // refetch when this close to side edge
// Travel angle: atan2(0.7, 1) ~ 35 deg. Normalised: cos=0.82, sin=0.57.
// The corridor is ROTATED to follow the actual travel direction so
// tiles aren't wasted in regions the camera never reaches.
var GROUND_TRAVEL_COS = 0.82; // cos(35 deg), = 1 / sqrt(1 + 0.49)
var GROUND_TRAVEL_SIN = 0.57; // sin(35 deg), = 0.7 / sqrt(1.49)
var GROUND_CORRIDOR_LONG = 15; // tiles along the travel direction
var GROUND_CORRIDOR_PERP = 5; // tiles perpendicular to travel
var GROUND_BUFFER_LONG = 2; // 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 = 1099;
var GROUND_BASE_Y = 706;
@@ -301,13 +305,17 @@ function fetchTileImage(tileX, tileY) {
});
}
function drawTileAt(dx, dz, centerTileX, centerTileY) {
function drawTileAt(lng, perp, dx, dz, centerTileX, centerTileY) {
var tileX = centerTileX + dx;
var tileY = centerTileY + dz;
var halfX = Math.floor(GROUND_CORRIDOR_X / 2);
var halfZ = Math.floor(GROUND_CORRIDOR_Z / 2);
var canvasX = (dx + halfX) * GROUND_TILE_SIZE;
var canvasY = (dz + halfZ) * GROUND_TILE_SIZE;
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 2);
// Canvas position is the rotated-frame coordinate (lng, perp),
// NOT the rotated tile offset (dx, dz). This way the texture
// is rectangular in canvas space and the shader maps world
// (hitPoint - centre) -> rotated axes -> canvas UV.
var canvasX = (lng + halfLong) * GROUND_TILE_SIZE;
var canvasY = (perp + halfPerp) * GROUND_TILE_SIZE;
if (canvasX < 0 || canvasX >= groundCanvas.width || canvasY < 0 || canvasY >= groundCanvas.height) {
return Promise.resolve(false);
}
@@ -328,15 +336,23 @@ function drawTileAt(dx, dz, centerTileX, centerTileY) {
// and loads every tile in the new range.
function rebuildGroundMosaic(centerTileX, centerTileY) {
groundCenterTile = { x: centerTileX, y: centerTileY };
var halfX = Math.floor(GROUND_CORRIDOR_X / 2);
var halfZ = Math.floor(GROUND_CORRIDOR_Z / 2);
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 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 dx = -halfX; dx <= halfX; dx++) {
for (var dz = -halfZ; dz <= halfZ; dz++) {
promises.push(drawTileAt(dx, dz, centerTileX, centerTileY));
// Iterate the corridor in its own rotated frame (long, perp),
// then map each (long, perp) to a world tile via rotation and to
// a canvas pixel via the iteration indices.
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);
promises.push(drawTileAt(lng, perp, dx, dz, centerTileX, centerTileY));
}
}
return Promise.all(promises).then(function() {
@@ -352,15 +368,18 @@ function rebuildGroundMosaic(centerTileX, centerTileY) {
function maybeRefetchGround(cameraWorldX, cameraWorldZ, gl) {
var cameraTileX = GROUND_BASE_X + Math.floor(cameraWorldX);
var cameraTileY = GROUND_BASE_Y + Math.floor(cameraWorldZ);
var halfX = Math.floor(GROUND_CORRIDOR_X / 2);
var halfZ = Math.floor(GROUND_CORRIDOR_Z / 2);
// Project camera offset onto the corridor's rotated axes.
var dx = cameraTileX - groundCenterTile.x;
var dy = cameraTileY - groundCenterTile.y;
// Only refetch when camera approaches the FRONT of the corridor
// (the only direction the camera moves toward). Side and back
// buffers keep refetches minimal.
if (dx > halfX - GROUND_BUFFER_X || dx < -halfX + GROUND_BUFFER_X ||
dy > halfZ - GROUND_BUFFER_Z || dy < -halfZ + GROUND_BUFFER_Z) {
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() {
@@ -375,8 +394,8 @@ function initGroundSystem(glCtx) {
if (groundCanvas) return; // already initialised
groundGl = glCtx;
groundCanvas = document.createElement('canvas');
groundCanvas.width = GROUND_TILE_SIZE * GROUND_CORRIDOR_X;
groundCanvas.height = GROUND_TILE_SIZE * GROUND_CORRIDOR_Z;
groundCanvas.width = GROUND_TILE_SIZE * GROUND_CORRIDOR_LONG;
groundCanvas.height = GROUND_TILE_SIZE * GROUND_CORRIDOR_PERP;
groundCtx = groundCanvas.getContext('2d');
// Pre-fill with fog colour so partially-loaded mosaic still
// shows the right base colour
@@ -501,7 +520,7 @@ window.clouds3dRender = function(target) {
y: groundCenterTile.y - GROUND_BASE_Y
};
gl.uniform2f(s.uGroundCenter, centerWorld.x, centerWorld.y);
gl.uniform2f(s.uGroundSize, GROUND_CORRIDOR_X, GROUND_CORRIDOR_Z);
gl.uniform2f(s.uGroundSize, GROUND_CORRIDOR_LONG, GROUND_CORRIDOR_PERP);
var drift = now * speed * 0.45;
if (performance.now() - groundLastCheck > 1500) {