Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 67 additions & 10 deletions apps/web/src/globe/GlobeCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,30 @@ function applyBuildingsGate(
// Flip the Google tileset/globe/credit trio in one place. No-ops when the
// state didn't change, so calling it from camera.changed stays cheap and
// requestRenderMode-friendly (render only requested on actual flips).
// While the keyless mesh is on screen the satellite globe is only a backdrop
// for the seconds before the mesh has loaded. At its normal detail it asked for
// 18 imagery tiles at a time, which is every request slot Cesium gives one
// server, and the mesh got none until they finished (measured 2026-10-06 on a
// 3162 px wide canvas: no mesh request in flight for 4 s behind 18 satellite
// ones). This error keeps the backdrop to a handful of coarse tiles.
const MESH_BACKDROP_GLOBE_SSE = 16;

// Tile demand grows with the pixel count. At one texel per pixel a 3162x1994
// canvas wanted 3 140 tiles and 2.5 GB for a single view and was still loading
// after 40 s. Above this many pixels the error scales so the demand stays what
// a 2.5 MP canvas asks for: the same view then finished in 16 s with 1 365
// tiles, and a full-resolution crop of the two is hard to tell apart
// (measured 2026-10-06). Smaller canvases keep one texel per pixel.
const MESH_PIXEL_BUDGET = 2_500_000;
function meshScreenSpaceError(canvas: HTMLCanvasElement): number {
return 24 * Math.max(1, Math.sqrt((canvas.width * canvas.height) / MESH_PIXEL_BUDGET));
}

function applyGoogleGate(
viewer: Cesium.Viewer,
tileset: Cesium.Cesium3DTileset,
wanted: boolean,
keyless: boolean,
): void {
const h = viewer.camera.positionCartographic.height;
const visible = wanted && h < GOOGLE_3D_MAX_CAMERA_HEIGHT_M;
Expand All @@ -112,12 +132,33 @@ function applyGoogleGate(
if (fxaa) fxaa.enabled = !sharp;
scene.requestRender();
}
if (keyless) {
const sse = meshScreenSpaceError(viewer.canvas);
if (Math.abs(tileset.maximumScreenSpaceError - sse) > 0.5) {
tileset.maximumScreenSpaceError = sse;
}
}
if (tileset.show !== visible) {
tileset.show = visible;
viewer.scene.globe.show = !visible;
// Google ToS requires visible attribution while their tiles render.
const globe = viewer.scene.globe;
if (keyless) {
// The globe stays up under our own mesh, coarse, as the backdrop: it is
// what shows through while tiles arrive. Hiding it, as the licensed path
// does, left a black map for the ~5 s the first tiles take. Left visible
// under a loaded mesh it changes at most 0.01 % of pixels (New York, San
// Francisco, Innsbruck, Rio), so there is nothing to gain by hiding it.
globe.show = true;
globe.maximumScreenSpaceError = visible
? MESH_BACKDROP_GLOBE_SSE
: presetKnobs(useSettings.getState().mapQuality).idleSSE;
} else {
globe.show = !visible;
}
// Google's terms require visible attribution while the LICENSED tiles
// render. The keyless mesh has no such strip: showing it there put the
// Cesium ion logo half under the time dock.
const credit = viewer.cesiumWidget.creditContainer as HTMLElement;
credit.style.display = visible ? '' : 'none';
credit.style.display = visible && !keyless ? '' : 'none';
viewer.scene.requestRender();
}
}
Expand Down Expand Up @@ -655,7 +696,12 @@ export function GlobeCanvas({
const setMotionQuality = (motion: boolean): void => {
if (viewer.isDestroyed()) return;
const k = presetKnobs(useSettings.getState().mapQuality);
scene.globe.maximumScreenSpaceError = motion ? k.motionSSE : k.idleSSE;
const meshUp = googleKeylessRef.current && googleTilesetRef.current?.show === true;
scene.globe.maximumScreenSpaceError = meshUp
? MESH_BACKDROP_GLOBE_SSE
: motion
? k.motionSSE
: k.idleSSE;
const fx = scene.postProcessStages?.fxaa;
// MSAA on means the photoreal mesh is up and FXAA must stay off.
if (fx) fx.enabled = !motion && scene.msaaSamples === 1;
Expand Down Expand Up @@ -889,7 +935,7 @@ export function GlobeCanvas({
// nothing changed, so this listener stays requestRenderMode-friendly.
const onCameraChanged = (): void => {
const ts = googleTilesetRef.current;
if (ts) applyGoogleGate(viewer, ts, googleWantedRef.current);
if (ts) applyGoogleGate(viewer, ts, googleWantedRef.current, googleKeylessRef.current);
const bld = osmBuildingsRef.current;
if (bld) applyBuildingsGate(viewer, bld, ts);
};
Expand Down Expand Up @@ -988,7 +1034,7 @@ export function GlobeCanvas({
// at unmount via scene.primitives.
googleWantedRef.current = false;
if (googleTilesetRef.current) {
applyGoogleGate(viewer, googleTilesetRef.current, false);
applyGoogleGate(viewer, googleTilesetRef.current, false, googleKeylessRef.current);
}
// Reset terrain to the cheap ellipsoid. Setting viewer.terrainProvider
// here also resets viewer.scene.terrainProvider.
Expand Down Expand Up @@ -1066,6 +1112,7 @@ export function GlobeCanvas({
if (googleTilesetRef.current && googleKeylessRef.current !== wantKeyless3d && wantGoogle) {
scene.primitives.remove(googleTilesetRef.current); // also destroys
googleTilesetRef.current = null;
scene.globe.show = true;
}
// The OSM boxes follow the Google mesh's visibility; they load on their
// own promise, so either may arrive first.
Expand Down Expand Up @@ -1095,7 +1142,14 @@ export function GlobeCanvas({
// (app/rocktree.py), no key of any kind. Otherwise the licensed
// stream, which needs the Google key or the ion token.
(keyless
? Cesium.Cesium3DTileset.fromUrl(backendUrl('/tiles/g3d/root.json'), googleOptions)
? Cesium.Cesium3DTileset.fromUrl(backendUrl('/tiles/g3d/root.json'), {
...googleOptions,
// Load the tiles the view needs and skip the chain of coarser
// ones above them: half the requests for the same picture
// (151 against 355 for one view). What has not arrived yet shows
// the satellite backdrop, never a hole.
skipLevelOfDetail: true,
})
: Cesium.createGooglePhotorealistic3DTileset(undefined, googleOptions)
)
.then((tileset) => {
Expand All @@ -1114,9 +1168,12 @@ export function GlobeCanvas({
return;
}
googleKeylessRef.current = keyless;
// Hidden first, so the gate below always sees a change and sets
// the backdrop up; a tileset is born with show = true.
if (keyless) tileset.show = false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
scene.primitives.add(tileset);
googleTilesetRef.current = tileset;
applyGoogleGate(viewer, tileset, googleWantedRef.current);
applyGoogleGate(viewer, tileset, googleWantedRef.current, keyless);
yieldBuildings();
})
.catch((e: unknown) => {
Expand All @@ -1126,13 +1183,13 @@ export function GlobeCanvas({
};
if (wantGoogle) {
if (googleTilesetRef.current) {
applyGoogleGate(viewer, googleTilesetRef.current, true);
applyGoogleGate(viewer, googleTilesetRef.current, true, googleKeylessRef.current);
yieldBuildings();
} else if (!googleCreatingRef.current) {
startGoogle(wantKeyless3d);
}
} else if (googleTilesetRef.current) {
applyGoogleGate(viewer, googleTilesetRef.current, false);
applyGoogleGate(viewer, googleTilesetRef.current, false, googleKeylessRef.current);
yieldBuildings();
} else {
scene.globe.show = true;
Expand Down
34 changes: 34 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,40 @@ Same view, same tiles, three things compared on magnified crops:
A finer level of detail than one texel per pixel (`GOOGLE_3D_DETAIL=48`) is
only slightly sharper for 2.5 times the tiles, so it is not the default.

**The mesh in use: a black map, a starved loader and a logo (2026-10-06).**
The operator reported the basemap as buggy on a large display. Reproduced by
doing what an operator does: pick Google 3D at world view, fly to a city.

- **Black map on arrival.** The gate hid the globe the moment the camera went
under 30 km, before one mesh tile existed: about five seconds of black with
aircraft dots on it. Under the keyless mesh the globe now stays visible as
the backdrop. Left on under a loaded mesh it changes at most 0.01 % of
pixels (New York, San Francisco, Innsbruck, Rio), so nothing is gained by
hiding it. The licensed stream still hides it: its heights are ellipsoidal.
- **The globe starved the mesh.** Cesium allows one server 18 requests at a
time. On a 3162 px wide canvas the satellite basemap took all 18 for four
seconds at a stretch and the mesh had none in flight. While the mesh is up
the globe now runs at screen-space error 16, a handful of coarse tiles.
- **Too many tiles on a big canvas.** One texel per pixel at 3162x1994 wanted
3 140 tiles and 2.5 GB for one view and had not finished after 40 s. Above
2.5 MP the mesh's error now scales with the pixel count, and it skips the
chain of coarser tiles above the ones it needs (`skipLevelOfDetail`). Same
view: 1 365 tiles, 1.1 GB, loaded in 16 s; a full-resolution crop of the two
is hard to tell apart. A 1920 px fly-in went from 16 s to 6 s.
- **A logo under the time dock.** Showing the mesh un-hid Cesium's credit
strip, which is right for the licensed stream and wrong here: it put the
Cesium ion logo half behind the dock. The strip stays hidden for this source.

Two things that look like bugs and are Google's data: a hard colour line
across Manhattan at 59th Street (two capture blocks; raw texture means
81/69/70 south of it and 96/109/123 north, same node epoch), and a blurred
patch on the left bank in Paris (a finest-level node, blurred at source).

Tried and dropped again: warming sibling nodes from Google (cold first visit
on a quiet API: 16/6/6 s with, 8/5/9 s without), and filling per column
instead of per octant (the per-octant rule is Google's own; 1 416 of 1 426
fillers in the cache are empty and the rest are small real slivers).

**The spread is the backend, not this code.** A cached tile takes the API a
median 8 ms but 319 ms at p99, with stalls to 1.9 s (4 072 requests, six at a
time), because the event loop shares a process with the 1 s ADS-B cycle. Every
Expand Down
Loading