From b9069867182061391d5c269e7e075e8566370ee7 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 13 Oct 2026 21:53:16 +0200 Subject: [PATCH] perf: add distance-dependent terrain mesh LOD --- README.md | 7 +- config.js | 10 +++ vendor/clouds3d.js | 157 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 151 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f0837cc..0605c17 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Groups: | `fog.distanceKm` | distance haze (0/false = off) | | `clouds` | deck height (`top`/`bottom` in world units), `density` per weather level, `wind` motion, soft edge/horizon fading, `maxMarchUnits` and `terrainOcclusion` | | `flights` | aircraft count, altitudes, speeds, destination weights, contrails, night strobes | -| `terrain` | DEM mesh, `exaggeration`, edge fade/skirt, `outerDem`, `carLights`, `cityLights`, `wireframe` | +| `terrain` | DEM mesh, `exaggeration`, edge fade/skirt, `outerDem`, distance-dependent `lod`, `carLights`, `cityLights`, `wireframe` | | `carLights` | road width, light sizes, lane separation, traffic range/fade, `trafficSide`, spacing, speed, density, brightness | | `lights` | twilight switching window of every artificial light (`offDeg`, `onDeg`, `softDeg`) | | `atmosphere` | turbidity, moon/star gains, night floor, albedo/sky/air gains, haze blocks, cloud albedo/haze, cloud shadows, terrain shadows | @@ -303,8 +303,13 @@ Other main performance knobs: - `antialias: false`: disables MSAA. - `maxFps`: caps the animation loop. - `clouds.maxMarchUnits`: maximum cloud raymarch length. +- Cloud raymarch uses 48 samples with distance-adaptive spacing: finer near + the camera, coarser toward the march limit. - `clouds.softEdges`, `edgeSoftness`, `horizonSoftness`: soft cloud edges; disabling `softEdges` restores the old hard shell behaviour. +- `terrain.lod`: distance-dependent terrain mesh LOD; fine near the centre, + coarser toward the border. Set `terrain.lod.enabled: false` for the old + uniform mesh grid. - `atmosphere.cloudShadows` and `atmosphere.terrainShadows`: disable if the scene is GPU-bound. - `flights.count`, `flights.contrailChance`, `carLights.density`, diff --git a/config.js b/config.js index 94aa526..abfe0e8 100644 --- a/config.js +++ b/config.js @@ -474,6 +474,16 @@ window.CONFIG = { 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 + // Distance-dependent terrain mesh LOD: fine near the mosaic centre, + // coarser toward the border. The outer skirt remains the farthest LOD. + lod: { + enabled: true, + nearStepPx: 32, // mesh vertex spacing near the centre + farStepPx: 96, // mesh vertex spacing near the border + innerTiles: 8, // fully fine area around the centre + outerTiles: 20 // fully coarse area toward the border + // lod: { enabled: false } // old uniform mesh + }, outerDem: { enabled: true, // false uses procedural fallback heights zoom: 7, // reduced-resolution terrarium zoom for the skirt diff --git a/vendor/clouds3d.js b/vendor/clouds3d.js index 4d75cdf..e12ab62 100644 --- a/vendor/clouds3d.js +++ b/vendor/clouds3d.js @@ -1591,6 +1591,16 @@ 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 TERRAIN_LOD_CFG = TERRAIN_CFG.lod || {}; +var TERRAIN_LOD_ON = TERRAIN_LOD_CFG.enabled !== false; +var TERRAIN_LOD_NEAR_PX = (typeof TERRAIN_LOD_CFG.nearStepPx === 'number') + ? Math.max(8, Math.min(128, TERRAIN_LOD_CFG.nearStepPx)) : 32; +var TERRAIN_LOD_FAR_PX = (typeof TERRAIN_LOD_CFG.farStepPx === 'number') + ? Math.max(TERRAIN_LOD_NEAR_PX, Math.min(256, TERRAIN_LOD_CFG.farStepPx)) : 96; +var TERRAIN_LOD_INNER_TILES = (typeof TERRAIN_LOD_CFG.innerTiles === 'number') + ? Math.max(1, Math.min(64, TERRAIN_LOD_CFG.innerTiles)) : 8; +var TERRAIN_LOD_OUTER_TILES = (typeof TERRAIN_LOD_CFG.outerTiles === 'number') + ? Math.max(TERRAIN_LOD_INNER_TILES + 1, Math.min(64, TERRAIN_LOD_CFG.outerTiles)) : 20; 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, @@ -2584,24 +2594,77 @@ function buildTerrainMesh(gl, state) { } var tilesX = groundTilesX(), tilesY = groundTilesY(); var halfX = tilesX / 2, halfY = tilesY / 2; - // Decode elevation in metres on the sampling grid: one vertex per - // STEP canvas px (z10 mosaic ~101 m/px source -> ~3.2 km mesh step, - // 4x finer per axis than the old 13 km tile-centre grid that made - // ridges look faceted). - var STEP = 32; // canvas px between mesh vertices - var s = STEP / ELEV_UNIT_PX; // world units (tiles) per grid step - var gw = Math.floor((W - 1) / STEP) + 1; - var gh = Math.floor((H - 1) / STEP) + 1; + // Decode elevation in metres on a distance-dependent LOD grid: fine near + // the mosaic centre, coarser toward the border. The outer skirt remains + // the farthest LOD stage. + function makeLodPositions(size, nearStep, farStep, innerPx, outerPx) { + var center = (size - 1) * 0.5; + var pos = [center]; + function smooth01(t) { + t = Math.max(0, Math.min(1, t)); + return t * t * (3 - 2 * t); + } + function stepAt(p) { + var d = Math.abs(p - center); + var t = (outerPx > innerPx) ? smooth01((d - innerPx) / (outerPx - innerPx)) : 0; + return Math.max(1, nearStep + (farStep - nearStep) * t); + } + var p = center; + while (p < size - 1) { + p = Math.min(size - 1, p + stepAt(p)); + pos.push(p); + } + p = center; + while (p > 0) { + p = Math.max(0, p - stepAt(p)); + pos.push(p); + } + pos.push(0); + pos.push(size - 1); + pos.sort(function(a, b) { return a - b; }); + var out = []; + for (var i = 0; i < pos.length; i++) { + var v = pos[i]; + if (!out.length || Math.abs(v - out[out.length - 1]) > 0.5) out.push(v); + } + out[0] = 0; + out[out.length - 1] = size - 1; + return out; + } + + var UNIFORM_STEP = 32; + var xs, ys; + if (TERRAIN_LOD_ON) { + var innerPx = TERRAIN_LOD_INNER_TILES * ELEV_UNIT_PX; + var outerPx = TERRAIN_LOD_OUTER_TILES * ELEV_UNIT_PX; + xs = makeLodPositions(W, TERRAIN_LOD_NEAR_PX, TERRAIN_LOD_FAR_PX, innerPx, outerPx); + ys = makeLodPositions(H, TERRAIN_LOD_NEAR_PX, TERRAIN_LOD_FAR_PX, innerPx, outerPx); + } else { + xs = []; + ys = []; + for (var ux = 0; ux < W - 1; ux += UNIFORM_STEP) xs.push(ux); + xs.push(W - 1); + for (var uy = 0; uy < H - 1; uy += UNIFORM_STEP) ys.push(uy); + ys.push(H - 1); + } + + var gw = xs.length; + var gh = ys.length; + var xUnits = new Float32Array(gw); + var yUnits = new Float32Array(gh); + for (var xi = 0; xi < gw; xi++) xUnits[xi] = xs[xi] / ELEV_UNIT_PX; + for (var yi = 0; yi < gh; yi++) yUnits[yi] = ys[yi] / ELEV_UNIT_PX; + var h = new Float32Array(gw * gh); for (var j = 0; j < gh; j++) { - var py = Math.min(H - 1, j * STEP); + var py = Math.max(0, Math.min(H - 1, Math.round(ys[j]))); var row = j * gw; for (var i = 0; i < gw; i++) { // mirrored X: the DEM mosaic runs west-right while the world // axis +x runs east (verified against the street texture) - // read the height column mirrored so ridges sit on the same // side as the roads. - var pxx = Math.max(0, W - 1 - i * STEP); + var pxx = Math.max(0, Math.min(W - 1, W - 1 - Math.round(xs[i]))); var idx = (py * W + pxx) * 4; h[row + i] = data[idx] * 256 + data[idx + 1] + data[idx + 2] / 256 - 32768; } @@ -2637,9 +2700,9 @@ function buildTerrainMesh(gl, state) { // horizon instead of ending in a cut-off ridge line. for (var ej = 0; ej < gh; ej++) { var erow = ej * gw; - var dz = Math.min(ej, gh - 1 - ej) * s; + var dz = Math.min(yUnits[ej], tilesY - yUnits[ej]); for (var ei = 0; ei < gw; ei++) { - var dx = Math.min(ei, gw - 1 - ei) * s; + var dx = Math.min(xUnits[ei], tilesX - xUnits[ei]); var t = Math.max(0, Math.min(1, Math.min(dx, dz) / EDGE_FADE_TILES)); t = t * t * (3 - 2 * t); h[erow + ei] = h[erow + ei] * t - (1 - t) * EDGE_SINK_TILES; @@ -2664,14 +2727,15 @@ function buildTerrainMesh(gl, state) { function setVertex(i, j) { var iL = Math.max(0, i - 1), iR = Math.min(gw - 1, i + 1); var jD = Math.max(0, j - 1), jU = Math.min(gh - 1, j + 1); - var dx = (hAt(iR, j) - hAt(iL, j)) / ((iR - iL) * s); - var dz = (hAt(i, jU) - hAt(i, jD)) / ((jU - jD) * s); + var dx = (hAt(iR, j) - hAt(iL, j)) / Math.max(1e-6, xUnits[iR] - xUnits[iL]); + var dz = (hAt(i, jU) - hAt(i, jD)) / Math.max(1e-6, yUnits[jU] - yUnits[jD]); var nx = -dx, ny = 1, nz = -dz; var nl = Math.hypot(nx, ny, nz) || 1; nx /= nl; ny /= nl; nz /= nl; - verts[vi++] = i * s - halfX; + var _x = xUnits[i] - halfX, _z = halfY - yUnits[j]; + verts[vi++] = _x; verts[vi++] = hAt(i, j); - verts[vi++] = halfY - j * s; + verts[vi++] = _z; verts[vi++] = nx; verts[vi++] = ny; verts[vi++] = nz; @@ -2679,7 +2743,6 @@ function buildTerrainMesh(gl, state) { // world +x (verified against landmarks), so u = 0.5 - x/tilesX. // Heights are read with the same mirror (see decode loop), so // ridges and roads agree. V matches the cloud-pass convention. - 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 @@ -2754,8 +2817,8 @@ function buildTerrainMesh(gl, state) { function add(i, j) { var o = outwardFor(i, j); loop.push({ - x: i * s - halfX, - z: halfY - j * s, + x: xUnits[i] - halfX, + z: halfY - yUnits[j], y: hAt(i, j), ox: o[0], oz: o[1] }); @@ -2813,9 +2876,9 @@ function buildTerrainMesh(gl, state) { var wire = new Float32Array(wireVerts); var wi = 0; function pushVert(i, j) { - wire[wi++] = i * s - halfX; + wire[wi++] = xUnits[i] - halfX; wire[wi++] = h[j * gw + i] * ELEV_SCALE_UNITS; - wire[wi++] = halfY - j * s; + wire[wi++] = halfY - yUnits[j]; } for (var j = 0; j < rows; j++) { for (var i = 0; i < cols - 1; i++) { @@ -2837,7 +2900,57 @@ function buildTerrainMesh(gl, state) { state.terrain.wireVbo = wireBuf; state.terrain.wireVertCount = (cols - 1) * rows * 2 + cols * (rows - 1) * 2; } - terrainSample = { h: h, gw: gw, gh: gh, s: s, halfX: halfX, halfY: halfY }; + // Sampling grid for cars/settlements stays uniform even when the visual + // mesh uses distance-dependent LOD. This keeps the car/city worker code + // simple and stable; the mesh itself is the LOD-optimised surface. + var SAMPLE_STEP = 32; + var sampleS = SAMPLE_STEP / ELEV_UNIT_PX; + var sgw = Math.floor((W - 1) / SAMPLE_STEP) + 1; + var sgh = Math.floor((H - 1) / SAMPLE_STEP) + 1; + var sh = new Float32Array(sgw * sgh); + for (var sj = 0; sj < sgh; sj++) { + var spy = Math.min(H - 1, sj * SAMPLE_STEP); + var srow = sj * sgw; + for (var si = 0; si < sgw; si++) { + var spx = Math.max(0, W - 1 - si * SAMPLE_STEP); + var sidx = (spy * W + spx) * 4; + sh[srow + si] = data[sidx] * 256 + data[sidx + 1] + data[sidx + 2] / 256 - 32768; + } + } + function shblur(src, out) { + for (var bj = 0; bj < sgh; bj++) { + var brow = bj * sgw; + for (var bi = 0; bi < sgw; bi++) { + var a = src[brow + (bi > 0 ? bi - 1 : 0)]; + var b = src[brow + bi]; + var c = src[brow + (bi < sgw - 1 ? bi + 1 : sgw - 1)]; + out[brow + bi] = (a + 2 * b + c) * 0.25; + } + } + } + function svblur(src, out) { + for (var bj = 0; bj < sgh; bj++) { + var jm = (bj > 0 ? bj - 1 : 0) * sgw; + var jp = (bj < sgh - 1 ? bj + 1 : sgh - 1) * sgw; + var brow = bj * sgw; + for (var bi = 0; bi < sgw; bi++) { + out[brow + bi] = (src[jm + bi] + 2 * src[brow + bi] + src[jp + bi]) * 0.25; + } + } + } + var st1 = new Float32Array(sgw * sgh); + shblur(sh, st1); svblur(st1, sh); + for (var sej = 0; sej < sgh; sej++) { + var serow = sej * sgw; + var sdz = Math.min(sej, sgh - 1 - sej) * sampleS; + for (var sei = 0; sei < sgw; sei++) { + var sdx = Math.min(sei, sgw - 1 - sei) * sampleS; + var st = Math.max(0, Math.min(1, Math.min(sdx, sdz) / EDGE_FADE_TILES)); + st = st * st * (3 - 2 * st); + sh[serow + sei] = sh[serow + sei] * st - (1 - st) * EDGE_SINK_TILES; + } + } + terrainSample = { h: sh, gw: sgw, gh: sgh, s: sampleS, halfX: halfX, halfY: halfY }; // Terrain just became available: (re)build both off-thread layers. They // upload themselves once the worker answers, so nothing here blocks. kickCarBuild(false);