diff --git a/config.js b/config.js index fd10f81..10b6845 100644 --- a/config.js +++ b/config.js @@ -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) diff --git a/vendor/clouds3d.js b/vendor/clouds3d.js index 07a7a7a..38523cb 100644 --- a/vendor/clouds3d.js +++ b/vendor/clouds3d.js @@ -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);