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).
This commit is contained in:
German Khokhlov committed 2026-10-16 15:06:15 +04:00
1 parent 6dad3894bd
commit da6141be4b
6 files changed
+152 -49

No files matched your search

+7 -1
View File
@@ -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,
},
};
}
@@ -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');
});
+18 -6
View File
@@ -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 };
+20
View File
@@ -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');
});
+75 -42
View File
@@ -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 {
+25
View File
@@ -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',
);
});