fix(globe): Navara sampleHeight under a camera looking ahead

The ground under a tilted camera is outside the frustum, where Navara
hides its tiles, so the ray found nothing. When the shown tiles miss,
the loaded hidden ones answer, the finest first (within 1.5 times its
radius, which keeps out the quadtree parent). At the Austin startup
pose it agrees with Cesium within 0.12-1.04 m.
This commit is contained in:
German Khokhlov committed 2026-10-16 14:29:31 +04:00
1 parent a8a2ad5582
commit 1cca89adf2
2 files changed
+90 -13

No files matched your search

+54 -10
View File
@@ -1,6 +1,8 @@
// GlobeSurface on Navara (S2): the height of the rendered scene is a CPU ray
// straight down the ellipsoid normal against the 3D-tile meshes Navara shows.
// Tile positions are float32 relative to the tile centre, which sits in the
// Where the shown tiles do not reach (outside the frustum), the loaded
// tiles Navara hides answer, the finest first. Tile positions are float32
// relative to the tile centre, which sits in the
// float64 matrixWorld with no rotation, so a ray taken into a mesh's local
// frame gives Earth-fixed metres. Navara frees the CPU arrays after upload;
// retainTileGeometry (internals.js) keeps positions and indices. Without a
@@ -56,18 +58,23 @@ const b = new Vector3();
const c = new Vector3();
const point = new Vector3();
// Meshes with CPU positions under the shown tile roots, minus `exclude`.
// 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 meshes = [];
const stack = roots.filter((root) => root.visible && !exclude.has(root));
const shown = [];
const hidden = [];
const stack = roots
.filter((root) => !exclude.has(root))
.map((root) => [root, root.visible]);
while (stack.length) {
const object = stack.pop();
const [object, visible] = stack.pop();
if (object.isMesh && object.geometry?.attributes?.position?.array)
meshes.push(object);
(visible ? shown : hidden).push(object);
for (const child of object.children)
if (child.visible && !exclude.has(child)) stack.push(child);
if (!exclude.has(child)) stack.push([child, visible && child.visible]);
}
return meshes;
return { shown, hidden };
}
// Nearest hit along worldRay with one mesh, in metres. Not Mesh.raycast: that
@@ -130,6 +137,36 @@ 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) {
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]);
}
candidates.sort((x, y) => x[0] - y[0]);
let limit = Infinity;
let nearest = Infinity;
for (const [radius, mesh] of candidates) {
if (radius > limit) break;
const distance = meshHitDistance(mesh, ray.far);
if (!Number.isFinite(distance)) continue;
if (limit === Infinity) limit = 1.5 * radius;
nearest = Math.min(nearest, distance);
}
return nearest;
}
/**
* @param {{ sampleTerrainHeight: (place: { lat: number, lng: number }) => number | undefined }} view
* @param {{
@@ -157,8 +194,15 @@ export function createNavaraSurface(view, hooks) {
const sampleHeight = (place, exclude) => {
if (!supported) return undefined;
const ray = surfaceRay(place);
const meshes = tileMeshes(hooks.tileRoots(), new Set(exclude ?? []));
return heightAlongRay(ray, nearestHit(meshes, ray)) ?? terrainHeight(place);
const { shown, hidden } = tileMeshes(
hooks.tileRoots(),
new Set(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);
return heightAlongRay(ray, distance) ?? terrainHeight(place);
};
return {
surface: {
+36 -3
View File
@@ -108,7 +108,7 @@ test('sampleHeight hits the top of the loaded tiles and keeps tile geometry', ()
);
});
test('excluded and hidden objects are skipped; terrain is the fallback', () => {
test('excluded objects are skipped; terrain is the fallback', () => {
const roof = tileAt(10, 45, 300, 20);
const ground = tileAt(10, 45, 40);
const { surface } = setup({
@@ -117,14 +117,47 @@ test('excluded and hidden objects are skipped; terrain is the fallback', () => {
});
const place = cartographic.fromDegrees(10, 45, 0);
near(surface.sampleHeight(place, [roof.mesh]), 40, 0.05, 'excluded roof');
ground.tile.visible = false;
near(surface.sampleHeight(place, [roof.tile]), 7, 1e-9, 'terrain fallback');
near(
surface.sampleHeight(place, [roof.tile, ground.tile]),
7,
1e-9,
'terrain fallback',
);
assert.equal(
surface.sampleHeight(cartographic.fromDegrees(11, 46, 0)),
undefined,
);
});
test('outside the view, the loaded tiles Navara hides answer: the finest one', () => {
// Navara hides the tiles outside the frustum and the parents it refines
// (visible = false); a camera looking ahead does not see the ground under
// it. A fine tile in view elsewhere, a hidden fine tile and its hidden
// coarse parent (300 m wide, 3 m higher) under the camera.
const inView = tileAt(10.01, 45, 50);
const fine = tileAt(10, 45, 40);
const coarse = tileAt(10, 45, 43, 300);
fine.tile.visible = false;
coarse.tile.visible = false;
const { surface } = setup({
tiles: [inView.tile, coarse.tile, fine.tile],
terrain: () => 7,
});
const place = cartographic.fromDegrees(10, 45, 0);
near(surface.sampleHeight(place), 40, 0.05, 'the finest hidden tile');
near(
surface.sampleHeight(cartographic.fromDegrees(10.01, 45, 0)),
50,
0.05,
'a shown tile first',
);
// A shown tile wins over hidden ones at the same place.
fine.tile.visible = true;
coarse.tile.visible = true;
near(surface.sampleHeight(place), 43, 0.05, 'shown: the top surface');
near(surface.sampleHeight(place, [fine.tile, coarse.tile]), 7, 1e-9);
});
test('terrainHeight reads Navara terrain in degrees; clampToHeight moves along the normal', () => {
const ground = tileAt(5, 50, 120);
const seen = [];