mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
6dad3894bd
commit
da6141be4b
6 files changed
+152
-49
No files matched your search
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user