From da6141be4b87862752fb752f4b166043fd8d948c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH] perf(globe): lazy hidden tiles by tile extent; floor resample interval sampleHeight collects hidden tiles only after the shown ones miss, with no per-node allocation, and picks the finest by tile extent (Navara exposes no geometric error), not per-mesh radius. The ground floor resamples on a move only after 100 ms, and reports its own ground and margin (engine.groundFloor, Navara-only diagnostics). --- src/globe/navara/createNavaraEngine.js | 8 +- src/globe/navara/createNavaraEngine.test.mjs | 7 ++ src/globe/navara/groundFloor.js | 24 +++- src/globe/navara/groundFloor.test.mjs | 20 ++++ src/globe/navara/surface.js | 117 ++++++++++++------- src/globe/navara/surface.test.mjs | 25 ++++ 6 files changed, 152 insertions(+), 49 deletions(-) diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 8e4f3434f..dbd4d5d4a 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -18,7 +18,7 @@ import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; -import { createGroundFloor } from './groundFloor.js'; +import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; const { cartesian3, math } = geo; @@ -231,5 +231,11 @@ export function createNavaraEngine( sharpenPort?.destroy(); }, isDestroyed: () => destroyed, + // Navara only, not contract: the ground the floor last sampled under + // the camera and the margin it keeps above it (QA measures against it). + groundFloor: { + ground: () => groundFloor.ground(), + margin: GROUND_FLOOR.MIN_ABOVE, + }, }; } diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 0f98dad22..2ceab2c31 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -369,3 +369,10 @@ test('the floor does not fight flights or a lookAt target', () => { near(heightOf(engine), 130, 1e-6, 'lifted once released'); engine.destroy(); }); + +test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => { + const { engine } = setup(); + assert.equal(typeof engine.groundFloor.ground, 'function'); + assert.equal(engine.groundFloor.margin, 30); + assert.equal(engine.groundFloor.ground(), undefined, 'nothing sampled yet'); +}); diff --git a/src/globe/navara/groundFloor.js b/src/globe/navara/groundFloor.js index 53e8e12ea..e670b1a7d 100644 --- a/src/globe/navara/groundFloor.js +++ b/src/globe/navara/groundFloor.js @@ -37,6 +37,11 @@ export const GROUND_FLOOR = Object.freeze({ RESAMPLE_METRES: 20, /** Age (ms) after which the ground is sampled again. */ RESAMPLE_MS: 250, + /** + * Age (ms) before a move resamples: a sample of the ground outside the + * view costs about 1.6 ms (surface.js), too much for every frame. + */ + MIN_RESAMPLE_MS: 100, }); /** @@ -81,12 +86,19 @@ export function createGroundFloor({ 0, WGS84, ); - if ( - sample && - at - sample.at < GROUND_FLOOR.RESAMPLE_MS && - cartesian3.distance(point, sample.point) < GROUND_FLOOR.RESAMPLE_METRES - ) - return sample.height; + // Again after RESAMPLE_MS, or after a RESAMPLE_METRES move once + // MIN_RESAMPLE_MS has passed (a pan does not sample every frame). + if (sample) { + const age = at - sample.at; + const moved = + cartesian3.distance(point, sample.point) >= + GROUND_FLOOR.RESAMPLE_METRES; + if ( + age < GROUND_FLOOR.RESAMPLE_MS && + !(moved && age >= GROUND_FLOOR.MIN_RESAMPLE_MS) + ) + return sample.height; + } const height = surface.sampleHeight(place) ?? surface.terrainHeight?.(place) ?? 0; sample = { point, at, height }; diff --git a/src/globe/navara/groundFloor.test.mjs b/src/globe/navara/groundFloor.test.mjs index 9f80a109b..a5a8ff83b 100644 --- a/src/globe/navara/groundFloor.test.mjs +++ b/src/globe/navara/groundFloor.test.mjs @@ -130,6 +130,26 @@ test('the ground is resampled after a move or a while, and the clamp only on rea state.position = at(2.36, 48.85, 500); state.ground = 140; floor.update(); + assert.equal(state.samples, 2, 'not within MIN_RESAMPLE_MS of the last'); + state.time += GROUND_FLOOR.MIN_RESAMPLE_MS; + floor.update(); assert.equal(state.samples, 3, 'resampled after a move'); assert.equal(state.clamps.length, 2, 'a 40 m change is sent'); }); + +test('a pan resamples at most every MIN_RESAMPLE_MS, however far it moves', () => { + // A sample of the ground outside the view costs about 1.6 ms (surface.js): + // not on every frame of a pan. + assert.ok(GROUND_FLOOR.MIN_RESAMPLE_MS >= 100); + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 500); + floor.update(); + for (let frame = 1; frame <= 30; frame++) { + state.time = frame * 16; + state.position = at(2.35 + frame * 0.001, 48.85, 500); // ~73 m a frame + floor.update(); + } + // 480 ms of panning: the first sample and one every 100 ms at most. + assert.ok(state.samples <= 1 + Math.ceil(480 / GROUND_FLOOR.MIN_RESAMPLE_MS)); + assert.ok(state.samples >= 3, 'still follows the pan'); +}); diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index 45fa27f51..67fb3bcbc 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -58,25 +58,23 @@ const b = new Vector3(); const c = new Vector3(); const point = new Vector3(); -// Meshes with CPU positions under the tile roots, minus `exclude`: the -// shown ones, and the loaded ones Navara hides (outside the frustum, or -// parents of the tiles it shows), which still hold their geometry. -function tileMeshes(roots, exclude) { - const shown = []; - const hidden = []; - const stack = roots - .filter((root) => !exclude.has(root)) - .map((root) => [root, root.visible]); - while (stack.length) { - const [object, visible] = stack.pop(); - if (object.isMesh && object.geometry?.attributes?.position?.array) - (visible ? shown : hidden).push(object); - for (const child of object.children) - if (!exclude.has(child)) stack.push([child, visible && child.visible]); - } - return { shown, hidden }; +const hasPositions = (object) => + object.isMesh && Boolean(object.geometry?.attributes?.position?.array); + +// The meshes with CPU positions under `object`, minus `exclude`, into +// `out`; with `shownOnly`, only through visible objects. +function collectMeshes(object, exclude, shownOnly, out) { + if (exclude.has(object) || (shownOnly && !object.visible)) return; + if (hasPositions(object)) out.push(object); + const children = object.children; + for (let i = 0; i < children.length; i++) + collectMeshes(children[i], exclude, shownOnly, out); } +// Reused between calls (sampleHeight runs per frame in the ground floor). +const shownMeshes = []; +const tileMeshesScratch = []; + // Nearest hit along worldRay with one mesh, in metres. Not Mesh.raycast: that // interpolates UVs and normals, whose arrays Navara has released. function meshHitDistance(mesh, far) { @@ -137,33 +135,65 @@ function nearestHit(meshes, ray) { return nearest; } -// Among hidden tiles several levels of detail overlap: the hit on the -// finest tiles (bounding radius within 1.5 times the smallest hit: a -// quadtree parent is about twice its child), the top one -// among them. The tiles the ray passes are tried finest first, and coarser -// ones stop being tried once a hit sets the limit, so the coarse parents' -// triangles are never walked. -function finestHit(meshes, ray) { - worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z); - worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z); - const candidates = []; - for (const mesh of meshes) { +// A tile's extent: the bounding sphere around all its meshes, in +// Earth-fixed metres. Navara 0.2.1 exposes no geometric error or level on a +// tile, and a quadtree tile's extent halves with each level, so the extent +// orders them. Tiles do not move once loaded: computed once per tile. +const extents = new WeakMap(); +function tileExtent(root) { + let extent = extents.get(root); + if (extent) return extent; + tileMeshesScratch.length = 0; + collectMeshes(root, NO_EXCLUDE, false, tileMeshesScratch); + if (!tileMeshesScratch.length) return null; + const bounds = new Sphere(); + for (const mesh of tileMeshesScratch) { const geometry = mesh.geometry; if (!geometry.boundingSphere) geometry.computeBoundingSphere(); sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld); - if (worldRay.intersectsSphere(sphere)) - candidates.push([sphere.radius, mesh]); + if (bounds.isEmpty()) bounds.copy(sphere); + else bounds.union(sphere); } - candidates.sort((x, y) => x[0] - y[0]); + extent = bounds; + extents.set(root, extent); + return extent; +} +const NO_EXCLUDE = new Set(); + +// Where the shown tiles miss, the loaded tiles Navara hides (outside the +// frustum, or parents of the tiles it shows) answer. Several levels of +// detail overlap there: the hit on the finest tiles (extent within 1.5 +// times the finest tile hit; a quadtree parent is about twice its child), +// the top one among them. Tiles the ray passes are tried finest first, and +// coarser ones stop being tried once a hit sets the limit, so the coarse +// parents' triangles are never walked. +const hiddenTiles = []; +function finestHiddenHit(roots, exclude, ray) { + worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z); + worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z); + hiddenTiles.length = 0; + for (let i = 0; i < roots.length; i++) { + const root = roots[i]; + if (root.visible || exclude.has(root)) continue; + const extent = tileExtent(root); + if (extent && worldRay.intersectsSphere(extent)) hiddenTiles.push(root); + } + hiddenTiles.sort((x, y) => extents.get(x).radius - extents.get(y).radius); let limit = Infinity; let nearest = Infinity; - for (const [radius, mesh] of candidates) { + for (const root of hiddenTiles) { + const radius = extents.get(root).radius; if (radius > limit) break; - const distance = meshHitDistance(mesh, ray.far); - if (!Number.isFinite(distance)) continue; + tileMeshesScratch.length = 0; + collectMeshes(root, exclude, false, tileMeshesScratch); + let hit = Infinity; + for (const mesh of tileMeshesScratch) + hit = Math.min(hit, meshHitDistance(mesh, ray.far)); + if (!Number.isFinite(hit)) continue; if (limit === Infinity) limit = 1.5 * radius; - nearest = Math.min(nearest, distance); + nearest = Math.min(nearest, hit); } + hiddenTiles.length = 0; return nearest; } @@ -194,14 +224,17 @@ export function createNavaraSurface(view, hooks) { const sampleHeight = (place, exclude) => { if (!supported) return undefined; const ray = surfaceRay(place); - const { shown, hidden } = tileMeshes( - hooks.tileRoots(), - new Set(exclude ?? []), - ); + const roots = hooks.tileRoots(); + const excluded = exclude ? new Set(exclude) : NO_EXCLUDE; // The tiles on screen first; where they do not reach (the ground under - // a camera looking ahead is outside the frustum), the loaded hidden ones. - let distance = nearestHit(shown, ray); - if (!Number.isFinite(distance)) distance = finestHit(hidden, ray); + // a camera looking ahead is outside the frustum), the hidden ones. + shownMeshes.length = 0; + for (let i = 0; i < roots.length; i++) + collectMeshes(roots[i], excluded, true, shownMeshes); + let distance = nearestHit(shownMeshes, ray); + shownMeshes.length = 0; + if (!Number.isFinite(distance)) + distance = finestHiddenHit(roots, excluded, ray); return heightAlongRay(ray, distance) ?? terrainHeight(place); }; return { diff --git a/src/globe/navara/surface.test.mjs b/src/globe/navara/surface.test.mjs index 00b12d866..ffefb80a9 100644 --- a/src/globe/navara/surface.test.mjs +++ b/src/globe/navara/surface.test.mjs @@ -190,3 +190,28 @@ test('tilesLoaded follows Navara idle; a failed geometry patch means unsupported assert.equal(surface.supported, false); assert.equal(surface.clampToHeightSupported, false); }); + +test('among hidden tiles the finest tile wins by its extent, not by a small mesh in a coarse tile', () => { + // A coarse hidden tile (800 m across) carries a small 20 m mesh 5 m above + // the fine hidden tile's ground: by mesh size the coarse tile's small mesh + // would win; by tile extent the fine tile does. + const fine = tileAt(10, 45, 40); + const coarse = tileAt(10, 45, 30, 400); + const coarseRoof = tileAt(10, 45, 45, 10); + // One coarse tile with two meshes, both relative to its own centre. + coarseRoof.tile.remove(coarseRoof.mesh); + coarseRoof.mesh.position + .copy(coarseRoof.tile.position) + .sub(coarse.tile.position); + coarse.tile.add(coarseRoof.mesh); + coarse.tile.updateMatrixWorld(true); + fine.tile.visible = false; + coarse.tile.visible = false; + const { surface } = setup({ tiles: [coarse.tile, fine.tile] }); + near( + surface.sampleHeight(cartographic.fromDegrees(10, 45, 0)), + 40, + 0.05, + 'the fine tile', + ); +});