perf: add distance-dependent terrain mesh LOD

This commit is contained in:
Richard Zink committed 2026-10-13 21:53:16 +02:00
1 parent cf41b72133
commit b906986718
3 files changed
+151 -23

No files matched your search

+6 -1
View File
@@ -262,7 +262,7 @@ Groups:
| `fog.distanceKm` | distance haze (0/false = off) | | `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` | | `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 | | `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 | | `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`) | | `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 | | `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. - `antialias: false`: disables MSAA.
- `maxFps`: caps the animation loop. - `maxFps`: caps the animation loop.
- `clouds.maxMarchUnits`: maximum cloud raymarch length. - `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; - `clouds.softEdges`, `edgeSoftness`, `horizonSoftness`: soft cloud edges;
disabling `softEdges` restores the old hard shell behaviour. 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 - `atmosphere.cloudShadows` and `atmosphere.terrainShadows`: disable if the
scene is GPU-bound. scene is GPU-bound.
- `flights.count`, `flights.contrailChance`, `carLights.density`, - `flights.count`, `flights.contrailChance`, `carLights.density`,
+10
View File
@@ -474,6 +474,16 @@ window.CONFIG = {
edgeSkirtKm: 800, // coarse outer terrain pulled past the horizon edgeSkirtKm: 800, // coarse outer terrain pulled past the horizon
edgeSkirtRings: 24, // coarse rings in the outer skirt edgeSkirtRings: 24, // coarse rings in the outer skirt
edgeSkirtNoise: 0.28, // fallback height variation if outer DEM is off 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: { outerDem: {
enabled: true, // false uses procedural fallback heights enabled: true, // false uses procedural fallback heights
zoom: 7, // reduced-resolution terrarium zoom for the skirt zoom: 7, // reduced-resolution terrarium zoom for the skirt
+135 -22
View File
@@ -1591,6 +1591,16 @@ var TERRAIN_ON = TERRAIN_CFG.enabled === false ? 0.0 : 1.0;
var EDGE_FADE_TILES = (typeof TERRAIN_CFG.edgeFadeTiles === 'number') var EDGE_FADE_TILES = (typeof TERRAIN_CFG.edgeFadeTiles === 'number')
? Math.max(0.5, TERRAIN_CFG.edgeFadeTiles) : 4.0; ? Math.max(0.5, TERRAIN_CFG.edgeFadeTiles) : 4.0;
var EDGE_SINK_TILES = 0.06; 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_CFG = TERRAIN_CFG.outerDem || {};
var OUTER_DEM_ON = OUTER_DEM_CFG.enabled !== false && TERRAIN_ON > 0.5; var OUTER_DEM_ON = OUTER_DEM_CFG.enabled !== false && TERRAIN_ON > 0.5;
var OUTER_DEM_Z = Math.max(4, Math.min(11, var OUTER_DEM_Z = Math.max(4, Math.min(11,
@@ -2584,24 +2594,77 @@ function buildTerrainMesh(gl, state) {
} }
var tilesX = groundTilesX(), tilesY = groundTilesY(); var tilesX = groundTilesX(), tilesY = groundTilesY();
var halfX = tilesX / 2, halfY = tilesY / 2; var halfX = tilesX / 2, halfY = tilesY / 2;
// Decode elevation in metres on the sampling grid: one vertex per // Decode elevation in metres on a distance-dependent LOD grid: fine near
// STEP canvas px (z10 mosaic ~101 m/px source -> ~3.2 km mesh step, // the mosaic centre, coarser toward the border. The outer skirt remains
// 4x finer per axis than the old 13 km tile-centre grid that made // the farthest LOD stage.
// ridges look faceted). function makeLodPositions(size, nearStep, farStep, innerPx, outerPx) {
var STEP = 32; // canvas px between mesh vertices var center = (size - 1) * 0.5;
var s = STEP / ELEV_UNIT_PX; // world units (tiles) per grid step var pos = [center];
var gw = Math.floor((W - 1) / STEP) + 1; function smooth01(t) {
var gh = Math.floor((H - 1) / STEP) + 1; 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); var h = new Float32Array(gw * gh);
for (var j = 0; j < gh; j++) { 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; var row = j * gw;
for (var i = 0; i < gw; i++) { for (var i = 0; i < gw; i++) {
// mirrored X: the DEM mosaic runs west-right while the world // mirrored X: the DEM mosaic runs west-right while the world
// axis +x runs east (verified against the street texture) - // axis +x runs east (verified against the street texture) -
// read the height column mirrored so ridges sit on the same // read the height column mirrored so ridges sit on the same
// side as the roads. // 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; var idx = (py * W + pxx) * 4;
h[row + i] = data[idx] * 256 + data[idx + 1] + data[idx + 2] / 256 - 32768; 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. // horizon instead of ending in a cut-off ridge line.
for (var ej = 0; ej < gh; ej++) { for (var ej = 0; ej < gh; ej++) {
var erow = ej * gw; 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++) { 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)); var t = Math.max(0, Math.min(1, Math.min(dx, dz) / EDGE_FADE_TILES));
t = t * t * (3 - 2 * t); t = t * t * (3 - 2 * t);
h[erow + ei] = h[erow + ei] * t - (1 - t) * EDGE_SINK_TILES; h[erow + ei] = h[erow + ei] * t - (1 - t) * EDGE_SINK_TILES;
@@ -2664,14 +2727,15 @@ function buildTerrainMesh(gl, state) {
function setVertex(i, j) { function setVertex(i, j) {
var iL = Math.max(0, i - 1), iR = Math.min(gw - 1, i + 1); 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 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 dx = (hAt(iR, j) - hAt(iL, j)) / Math.max(1e-6, xUnits[iR] - xUnits[iL]);
var dz = (hAt(i, jU) - hAt(i, jD)) / ((jU - jD) * s); var dz = (hAt(i, jU) - hAt(i, jD)) / Math.max(1e-6, yUnits[jU] - yUnits[jD]);
var nx = -dx, ny = 1, nz = -dz; var nx = -dx, ny = 1, nz = -dz;
var nl = Math.hypot(nx, ny, nz) || 1; var nl = Math.hypot(nx, ny, nz) || 1;
nx /= nl; ny /= nl; nz /= nl; 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++] = hAt(i, j);
verts[vi++] = halfY - j * s; verts[vi++] = _z;
verts[vi++] = nx; verts[vi++] = nx;
verts[vi++] = ny; verts[vi++] = ny;
verts[vi++] = nz; verts[vi++] = nz;
@@ -2679,7 +2743,6 @@ function buildTerrainMesh(gl, state) {
// world +x (verified against landmarks), so u = 0.5 - x/tilesX. // world +x (verified against landmarks), so u = 0.5 - x/tilesX.
// Heights are read with the same mirror (see decode loop), so // Heights are read with the same mirror (see decode loop), so
// ridges and roads agree. V matches the cloud-pass convention. // 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++] = 0.5 - _x / tilesX;
verts[vi++] = _z / tilesY + 0.5; verts[vi++] = _z / tilesY + 0.5;
// Detailed terrain uses the basemap texture; the color attribute is // Detailed terrain uses the basemap texture; the color attribute is
@@ -2754,8 +2817,8 @@ function buildTerrainMesh(gl, state) {
function add(i, j) { function add(i, j) {
var o = outwardFor(i, j); var o = outwardFor(i, j);
loop.push({ loop.push({
x: i * s - halfX, x: xUnits[i] - halfX,
z: halfY - j * s, z: halfY - yUnits[j],
y: hAt(i, j), y: hAt(i, j),
ox: o[0], oz: o[1] ox: o[0], oz: o[1]
}); });
@@ -2813,9 +2876,9 @@ function buildTerrainMesh(gl, state) {
var wire = new Float32Array(wireVerts); var wire = new Float32Array(wireVerts);
var wi = 0; var wi = 0;
function pushVert(i, j) { 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++] = 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 j = 0; j < rows; j++) {
for (var i = 0; i < cols - 1; i++) { for (var i = 0; i < cols - 1; i++) {
@@ -2837,7 +2900,57 @@ function buildTerrainMesh(gl, state) {
state.terrain.wireVbo = wireBuf; state.terrain.wireVbo = wireBuf;
state.terrain.wireVertCount = (cols - 1) * rows * 2 + cols * (rows - 1) * 2; 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 // Terrain just became available: (re)build both off-thread layers. They
// upload themselves once the worker answers, so nothing here blocks. // upload themselves once the worker answers, so nothing here blocks.
kickCarBuild(false); kickCarBuild(false);