terrain: extend map with reduced-resolution outer height skirt

This commit is contained in:
Richard Zink committed 2026-10-12 14:16:06 +02:00
1 parent 4441619b8f
commit c8fa1b3260
2 files changed
+273 -6

No files matched your search

+8
View File
@@ -235,6 +235,14 @@ window.CONFIG = {
enabled: true, // false = flat plane (no DEM, no terrain shadows)
exaggeration: 8, // vertical scale (1 = real, nearly invisible here)
edgeFadeTiles: 4, // soft rim at the mosaic border, in tiles (~13 km)
edgeSkirtKm: 800, // coarse outer terrain pulled past the horizon
edgeSkirtRings: 24, // coarse rings in the outer skirt
edgeSkirtNoise: 0.28, // fallback height variation if outer DEM is off
outerDem: {
enabled: true,
zoom: 7, // reduced-resolution terrarium zoom for the skirt
maxMeters: 2500 // height normalization for skirt colors
},
carLights: true, // moving vehicle lights on the real road network
cityLights: true, // settlement lights from OSM place nodes
wireframe: false // true = draw the mesh edges (orientation debug)
+265 -6
View File
@@ -528,6 +528,7 @@ var TERRAIN_VERT_SRC = [
'attribute vec3 aPos;',
'attribute vec3 aNormal;',
'attribute vec2 aUV;',
'attribute vec3 aColor;',
'uniform mat4 uView;',
'uniform mat4 uProj;',
'uniform vec3 uCamPos;',
@@ -538,6 +539,7 @@ var TERRAIN_VERT_SRC = [
'varying float vY;',
'varying vec3 vViewPos;',
'varying vec3 vWorld;',
'varying vec3 vColor;',
'void main() {',
'vec3 pos = aPos;',
'vec3 n = aNormal;',
@@ -551,6 +553,7 @@ var TERRAIN_VERT_SRC = [
'}',
'vNormal = n;',
'vUV = aUV;',
'vColor = aColor;',
'vY = pos.y;',
'vWorld = pos;',
'vec4 vp = uView * vec4(pos, 1.0);',
@@ -677,6 +680,7 @@ var TERRAIN_FRAG_SRC = [
'varying float vY;',
'varying vec3 vViewPos;',
'varying vec3 vWorld;',
'varying vec3 vColor;',
CLOUD_SAMPLER_GLSL,
'void main() {',
'vec2 gUv = vUV;',
@@ -688,6 +692,9 @@ var TERRAIN_FRAG_SRC = [
'float edgeX = min(gUv.x, 1.0 - gUv.x) * uGroundSize.x;',
'float edgeZ = min(gUv.y, 1.0 - gUv.y) * uGroundSize.y;',
'float edgeFade = smoothstep(0.0, uEdgeFadeTiles, min(edgeX, edgeZ));',
'float outside = step(0.0, -gUv.x) + step(1.0001, gUv.x) +',
'step(0.0, -gUv.y) + step(1.0001, gUv.y);',
'if (outside > 0.0) edgeFade = 1.0;',
'vec3 col = texture2D(uGround, gUv).rgb;',
'vec3 n = normalize(vNormal);',
// House lights are not painted into the texture any more (the VIIRS
@@ -698,7 +705,8 @@ var TERRAIN_FRAG_SRC = [
// Direct beam (extinction-coloured by the JS air-mass model) +
// hemispherical skylight + moonlight. No hand-picked day/night
// ramp: the sun altitude drives colour and strength continuously.
'vec3 alb = texture2D(uGround, gUv).rgb * uAlbedoTint;',
'float outsideMask = clamp(outside, 0.0, 1.0);',
'vec3 alb = mix(texture2D(uGround, gUv).rgb, vColor, outsideMask) * uAlbedoTint;',
'float ndl = max(0.0, dot(n, uSunDir));',
// Cloud shadow: march from the surface point towards the sun
// through the cloud layer and accumulate optical depth. Same
@@ -881,6 +889,7 @@ function init(canvas) {
var terrainApos = terrainProg ? gl.getAttribLocation(terrainProg, 'aPos') : -1;
var terrainAnormal = terrainProg ? gl.getAttribLocation(terrainProg, 'aNormal') : -1;
var terrainAuv = terrainProg ? gl.getAttribLocation(terrainProg, 'aUV') : -1;
var terrainAcolor = terrainProg ? gl.getAttribLocation(terrainProg, 'aColor') : -1;
var terrainUview = terrainProg ? gl.getUniformLocation(terrainProg, 'uView') : null;
var terrainUproj = terrainProg ? gl.getUniformLocation(terrainProg, 'uProj') : null;
var terrainUground = terrainProg ? gl.getUniformLocation(terrainProg, 'uGround') : null;
@@ -962,6 +971,7 @@ function init(canvas) {
terrain: {
prog: terrainProg,
aPos: terrainApos, aNormal: terrainAnormal, aUV: terrainAuv,
aColor: terrainAcolor,
uView: terrainUview, uProj: terrainUproj,
uGround: terrainUground, uSunDir: terrainUsundir,
uGroundCenter: terrainUgroundcenter, uGroundSize: terrainUgroundsize,
@@ -1112,6 +1122,20 @@ var TERRAIN_ON = TERRAIN_CFG.enabled === false ? 0.0 : 1.0;
var EDGE_FADE_TILES = (typeof TERRAIN_CFG.edgeFadeTiles === 'number')
? Math.max(0.5, TERRAIN_CFG.edgeFadeTiles) : 4.0;
var EDGE_SINK_TILES = 0.06;
var OUTER_DEM_CFG = TERRAIN_CFG.outerDem || {};
var OUTER_DEM_ON = OUTER_DEM_CFG.enabled !== false && TERRAIN_ON > 0.5;
var OUTER_DEM_Z = Math.max(4, Math.min(11,
(typeof OUTER_DEM_CFG.zoom === 'number') ? OUTER_DEM_CFG.zoom : 7));
var OUTER_TILES_PER = 1 << (11 - OUTER_DEM_Z);
var OUTER_PX_PER_Z11 = 256 / OUTER_TILES_PER;
var outerElevCanvas = null, outerElevCtx = null, outerElevData = null;
var outerElevReady = false;
var outerHalfX = 0, outerHalfY = 0, outerW = 0, outerH = 0;
function terrainSkirtUnits() {
var km = (typeof TERRAIN_CFG.edgeSkirtKm === 'number')
? Math.max(0, TERRAIN_CFG.edgeSkirtKm) : 800;
return km / KM_PER_TILE;
}
var TERRAIN_EXAG = (typeof TERRAIN_CFG.exaggeration === 'number' &&
TERRAIN_CFG.exaggeration > 0) ? TERRAIN_CFG.exaggeration : 8;
// metres -> world units, including the vertical exaggeration
@@ -1797,6 +1821,119 @@ function fetchElevationGrid() {
next(0);
}
function loadOuterElevTile(zx, zy) {
var id = 'elev/' + OUTER_DEM_Z + '/' + zx + '/' + zy;
function place(img) {
var cx = (OUTER_TILES_PER * zx - GROUND_BASE_X + outerHalfX) * OUTER_PX_PER_Z11;
var cy = (OUTER_TILES_PER * zy - GROUND_BASE_Y + outerHalfY) * OUTER_PX_PER_Z11;
outerElevCtx.drawImage(img, cx, cy);
}
return openTileDB().then(function (db) {
return tileDbGet(db, id).then(function (rec) {
if (rec && rec.blob) {
return decodeToBitmap(rec.blob).then(place, function () {
return fetchFresh(db);
});
}
return fetchFresh(db);
});
});
function fetchFresh(db) {
return fetch(ELEV_TILE_URL + OUTER_DEM_Z + '/' + zx + '/' + zy + '.png')
.then(function (r) {
if (!r.ok) throw new Error('outer elev http ' + r.status);
return r.blob();
})
.then(function (blob) {
if (db) tileDbPut(db, { id: id, blob: blob, savedAt: Date.now() });
return decodeToBitmap(blob);
})
.then(place)
.catch(function (e) {
console.warn('[clouds3d] outer elev tile failed z' + OUTER_DEM_Z + '/' + zx + '/' + zy + ': ' + (e && e.message || e));
});
}
}
function fetchOuterElevationGrid() {
if (!outerElevCanvas || !outerElevCtx) return;
var jobs = [];
var zx0 = Math.floor((GROUND_BASE_X - outerHalfX) / OUTER_TILES_PER);
var zx1 = Math.floor((GROUND_BASE_X + outerHalfX) / OUTER_TILES_PER);
var zy0 = Math.floor((GROUND_BASE_Y - outerHalfY) / OUTER_TILES_PER);
var zy1 = Math.floor((GROUND_BASE_Y + outerHalfY) / OUTER_TILES_PER);
for (var zx = zx0; zx <= zx1; zx++)
for (var zy = zy0; zy <= zy1; zy++)
jobs.push([zx, zy]);
function next(i) {
if (i >= jobs.length) {
try {
outerElevData = outerElevCtx.getImageData(0, 0, outerW, outerH).data;
outerElevReady = true;
} catch (e) {
console.warn('[clouds3d] outer DEM getImageData failed: ' + (e && e.message || e));
outerElevReady = false;
}
if (outerElevReady && terrainVbo && elevGl) {
terrainVbo = null;
buildTerrainMesh(elevGl, clouds3dState);
}
return;
}
var n = Math.min(i + GROUND_QUEUE_CONC, jobs.length);
var batch = [];
for (var k = i; k < n; k++) batch.push(loadOuterElevTile(jobs[k][0], jobs[k][1]));
Promise.all(batch).then(function () {
setTimeout(function () { next(n); }, GROUND_QUEUE_YIELD_MS);
});
}
next(0);
}
function buildOuterElevationSystem() {
if (!OUTER_DEM_ON || outerElevCanvas) return;
var skirt = terrainSkirtUnits();
outerHalfX = groundTilesX() / 2 + skirt;
outerHalfY = groundTilesY() / 2 + skirt;
outerW = Math.max(16, Math.ceil(outerHalfX * 2 * OUTER_PX_PER_Z11));
outerH = Math.max(16, Math.ceil(outerHalfY * 2 * OUTER_PX_PER_Z11));
outerElevCanvas = document.createElement('canvas');
outerElevCanvas.width = outerW;
outerElevCanvas.height = outerH;
outerElevCtx = outerElevCanvas.getContext('2d');
outerElevCtx.fillStyle = 'rgb(128,0,0)';
outerElevCtx.fillRect(0, 0, outerW, outerH);
fetchOuterElevationGrid();
}
function outerHeightAt(x, z) {
if (!outerElevReady || !outerElevData) return null;
var px = Math.round((x + outerHalfX) * OUTER_PX_PER_Z11);
var py = Math.round((outerHalfY - z) * OUTER_PX_PER_Z11);
if (px < 0 || py < 0 || px >= outerW || py >= outerH) return null;
var idx = (py * outerW + px) * 4;
var m = outerElevData[idx] * 256 + outerElevData[idx + 1] + outerElevData[idx + 2] / 256 - 32768;
return m * ELEV_SCALE_UNITS;
}
function skirtColorForHeight(hUnits) {
var maxM = (typeof OUTER_DEM_CFG.maxMeters === 'number')
? Math.max(100, OUTER_DEM_CFG.maxMeters) : 2500;
var m = hUnits / ELEV_SCALE_UNITS;
var t = Math.max(0, Math.min(1, m / maxM));
var stops = [
[0.36, 0.42, 0.30],
[0.55, 0.52, 0.47],
[0.78, 0.75, 0.68],
[0.92, 0.90, 0.84]
];
var q = t * (stops.length - 1);
var i = Math.max(0, Math.min(stops.length - 2, Math.floor(q)));
var f = q - i;
var a = stops[i], b = stops[i + 1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function buildElevationSystem(glCtx) {
if (elevCanvas) return;
elevGl = glCtx;
@@ -1809,7 +1946,10 @@ function buildElevationSystem(glCtx) {
elevCtx.fillRect(0, 0, elevCanvas.width, elevCanvas.height);
elevTexture = elevGl.createTexture();
uploadElevTexture();
if (TERRAIN_ON > 0.5) fetchElevationGrid();
if (TERRAIN_ON > 0.5) {
fetchElevationGrid();
buildOuterElevationSystem();
}
}
// ---- 3D math helpers (used by the terrain mesh + view-proj matrices) ----
@@ -1988,10 +2128,17 @@ function buildTerrainMesh(gl, state) {
}
}
// Build vertex list: 2 triangles per cell, 6 vertices each.
// Attributes: position (3), normal (3), uv (2) = 8 floats per vertex.
// Attributes: position (3), normal (3), uv (2), color (3) = 11 floats.
var cols = gw - 1, rows = gh - 1;
var vCount = cols * rows * 6;
var verts = new Float32Array(vCount * 8);
var SKIRT_UNITS = terrainSkirtUnits();
var SKIRT_RINGS = Math.max(1, (typeof TERRAIN_CFG.edgeSkirtRings === 'number')
? TERRAIN_CFG.edgeSkirtRings : 24);
var SKIRT_NOISE = (typeof TERRAIN_CFG.edgeSkirtNoise === 'number')
? Math.max(0, Math.min(1, TERRAIN_CFG.edgeSkirtNoise)) : 0.28;
var skirtSegs = 2 * cols + 2 * rows;
var skirtVerts = SKIRT_UNITS > 0 ? skirtSegs * SKIRT_RINGS * 6 : 0;
var vCount = cols * rows * 6 + skirtVerts;
var verts = new Float32Array(vCount * 11);
var vi = 0;
// Per-vertex normals from central differences of the smoothed
// heightfield (Gouraud shading - no flat facets on the slopes).
@@ -2017,6 +2164,9 @@ function buildTerrainMesh(gl, state) {
var _x = i * s - halfX, _z = halfY - j * s;
verts[vi++] = 0.5 - _x / tilesX;
verts[vi++] = _z / tilesY + 0.5;
// Detailed terrain uses the basemap texture; the color attribute is
// only consumed by the outer skirt.
verts[vi++] = 1; verts[vi++] = 1; verts[vi++] = 1;
}
for (var j = 0; j < rows; j++) {
for (var i = 0; i < cols; i++) {
@@ -2027,6 +2177,110 @@ function buildTerrainMesh(gl, state) {
setVertex(i0, j0); setVertex(i1, j1); setVertex(i1, j0);
}
}
// ---- Outer skirt -------------------------------------------------
// Coarse outer terrain from reduced-resolution heightmap tiles. The
// skirt has no basemap texture; its colour comes from vertex heights.
function pushRaw(x, y, z, nx, ny, nz, u, v, cr, cg, cb) {
verts[vi++] = x; verts[vi++] = y; verts[vi++] = z;
verts[vi++] = nx; verts[vi++] = ny; verts[vi++] = nz;
verts[vi++] = u; verts[vi++] = v;
verts[vi++] = cr; verts[vi++] = cg; verts[vi++] = cb;
}
function uvFromXZ(x, z) {
return [0.5 - x / tilesX, z / tilesY + 0.5];
}
function skirtHeight(base, x, z, t) {
var real = outerHeightAt(x, z);
if (real === null) {
if (SKIRT_NOISE <= 0) return base;
var amp = SKIRT_NOISE * (0.15 + 0.85 * t);
var n = Math.sin(x * 0.11 + t * 2.1) * Math.cos(z * 0.09 - t * 1.7)
+ 0.5 * Math.sin((x * 0.7 + z * 0.5) * 0.13 + t * 3.3);
return base + n * amp;
}
if (t <= 0) return base;
var blend = Math.min(1, t * 2.0);
return base + (real - base) * blend;
}
function quadNormal(x0,y0,z0, x1,y1,z1, x2,y2,z2) {
var ax = x1 - x0, ay = y1 - y0, az = z1 - z0;
var bx = x2 - x0, by = y2 - y0, bz = z2 - z0;
var nx = ay * bz - az * by;
var ny = az * bx - ax * bz;
var nz = ax * by - ay * bx;
var nl = Math.hypot(nx, ny, nz);
if (!(nl > 1e-6)) return [0, 1, 0];
nx /= nl; ny /= nl; nz /= nl;
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
return [nx, ny, nz];
}
function ringT(k, rings) {
var u = k / rings;
return Math.pow(u, 1.35);
}
function outwardFor(i, j) {
var ox = 0, oz = 0;
if (j === 0) oz = 1;
else if (j === gh - 1) oz = -1;
if (i === 0) ox = -1;
else if (i === gw - 1) ox = 1;
if (ox !== 0 && oz !== 0) {
var inv = 1 / Math.SQRT2;
ox *= inv; oz *= inv;
}
return [ox, oz];
}
function boundaryLoop() {
var loop = [];
function add(i, j) {
var o = outwardFor(i, j);
loop.push({
x: i * s - halfX,
z: halfY - j * s,
y: hAt(i, j),
ox: o[0], oz: o[1]
});
}
for (var i = 0; i < gw; i++) add(i, 0);
for (var j = 1; j < gh; j++) add(gw - 1, j);
for (var i = gw - 2; i >= 0; i--) add(i, gh - 1);
for (var j = gh - 2; j >= 1; j--) add(0, j);
return loop;
}
function pushSkirtLoop() {
if (SKIRT_UNITS <= 0) return;
var loop = boundaryLoop();
for (var seg = 0; seg < loop.length; seg++) {
var a = loop[seg], b = loop[(seg + 1) % loop.length];
for (var k = 0; k < SKIRT_RINGS; k++) {
var t0 = ringT(k, SKIRT_RINGS);
var t1 = ringT(k + 1, SKIRT_RINGS);
var d0 = SKIRT_UNITS * t0, d1 = SKIRT_UNITS * t1;
var x00 = a.x + a.ox * d0, z00 = a.z + a.oz * d0;
var x01 = b.x + b.ox * d0, z01 = b.z + b.oz * d0;
var x10 = a.x + a.ox * d1, z10 = a.z + a.oz * d1;
var x11 = b.x + b.ox * d1, z11 = b.z + b.oz * d1;
var y00 = skirtHeight(a.y, x00, z00, t0);
var y01 = skirtHeight(b.y, x01, z01, t0);
var y10 = skirtHeight(a.y, x10, z10, t1);
var y11 = skirtHeight(b.y, x11, z11, t1);
var uv00 = uvFromXZ(x00, z00), uv01 = uvFromXZ(x01, z01);
var uv10 = uvFromXZ(x10, z10), uv11 = uvFromXZ(x11, z11);
var nrm = quadNormal(x00,y00,z00, x01,y01,z01, x10,y10,z10);
var nx = nrm[0], ny = nrm[1], nz = nrm[2];
var c00 = skirtColorForHeight(y00), c01 = skirtColorForHeight(y01);
var c10 = skirtColorForHeight(y10), c11 = skirtColorForHeight(y11);
pushRaw(x00, y00, z00, nx, ny, nz, uv00[0], uv00[1], c00[0], c00[1], c00[2]);
pushRaw(x01, y01, z01, nx, ny, nz, uv01[0], uv01[1], c01[0], c01[1], c01[2]);
pushRaw(x11, y11, z11, nx, ny, nz, uv11[0], uv11[1], c11[0], c11[1], c11[2]);
pushRaw(x00, y00, z00, nx, ny, nz, uv00[0], uv00[1], c00[0], c00[1], c00[2]);
pushRaw(x11, y11, z11, nx, ny, nz, uv11[0], uv11[1], c11[0], c11[1], c11[2]);
pushRaw(x10, y10, z10, nx, ny, nz, uv10[0], uv10[1], c10[0], c10[1], c10[2]);
}
}
}
pushSkirtLoop();
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
@@ -5517,7 +5771,7 @@ window.clouds3dRender = function(target) {
gl.uniform1f(terr.uEdgeFadeTiles, EDGE_FADE_TILES);
gl.bindBuffer(gl.ARRAY_BUFFER, terr.vbo);
var STRIDE = 8 * 4; // 8 floats per vertex
var STRIDE = 11 * 4; // 11 floats per vertex
if (terr.aPos >= 0) {
gl.enableVertexAttribArray(terr.aPos);
gl.vertexAttribPointer(terr.aPos, 3, gl.FLOAT, false, STRIDE, 0);
@@ -5530,6 +5784,10 @@ window.clouds3dRender = function(target) {
gl.enableVertexAttribArray(terr.aUV);
gl.vertexAttribPointer(terr.aUV, 2, gl.FLOAT, false, STRIDE, 24);
}
if (terr.aColor >= 0) {
gl.enableVertexAttribArray(terr.aColor);
gl.vertexAttribPointer(terr.aColor, 3, gl.FLOAT, false, STRIDE, 32);
}
gl.drawArrays(gl.TRIANGLES, 0, terr.vertCount);
// Wireframe overlay: draw the mesh edges as bright lines on top
@@ -5545,6 +5803,7 @@ window.clouds3dRender = function(target) {
gl.enableVertexAttribArray(terr.aPos);
gl.disableVertexAttribArray(terr.aNormal);
gl.disableVertexAttribArray(terr.aUV);
if (terr.aColor >= 0) gl.disableVertexAttribArray(terr.aColor);
gl.vertexAttribPointer(terr.aPos, 3, gl.FLOAT, false, 0, 0);
gl.depthMask(false);
gl.drawArrays(gl.LINES, 0, terr.wireVertCount);