mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
fix(street-level): budgeted mesh sampling, in-place cones, date line, horizon
From the IC8 review of #768, each with tests that fail before: - P1: a mesh batch no longer rebuilds the selected sequence's cones. The drawn cones are indexed by mesh cell; a batch that touches none of them is ignored and the affected cones are moved in place (no removeAll/add). - P2: mesh sampling keeps to its 6 ms slices. Requests are taken in inside the budget, only cells within range of the camera are queued, the queue is a persistent min-heap re-ranked in one pass only after a 100 m camera move, and the deadline starts before any work. Coverage keeps a bounding box per line and densifies a line only once it comes within range. On a synthetic dense tile (600 x 40 points): slice median ~10 ms -> 5 ms, max 18-30 ms -> 6.7 ms, refresh cost 11-24 ms -> 1.5-3 ms. - P2: image cones and the photo marker are horizon-culled like the overview dots (one createHorizonCull in view.js, shared by all three). - P2: a selection change recolours the previous zoom's lines still on screen and the old lines a cast swap keeps showing. - P2: distances (metresBetween), line densification and ground-corner requests go the short way round +-180 deg (wrapLon in tileMath.js), so nearest ordering, cone thinning, mesh range and cast lines are right across the date line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09fc10756c
commit
bd97d38a99
15 files changed
+1085
-164
No files matched your search
@@ -9,7 +9,11 @@ import { metresBetween, whenIdle } from './view.js';
|
||||
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run
|
||||
* in idle time within a small budget, nearest the camera first, and only
|
||||
* within MESH_SAMPLE_RADIUS_M of the point under the camera: the streamed
|
||||
* detail is fine there, and an error is visible at all. A probe that hits
|
||||
* detail is fine there, and an error is visible at all. Requests are taken
|
||||
* in by the same budgeted slices, cells out of range then are not queued
|
||||
* (callers ask again when the camera moves), and the queue is a heap ranked
|
||||
* from where the camera was, re-ranked only once it has moved MESH_RERANK_M,
|
||||
* so no slice walks or sorts the whole queue. A probe that hits
|
||||
* nothing (tiles not streamed yet) is retried later rather than latched.
|
||||
* Overlays are excluded from the probe, so only 3D tilesets are hit.
|
||||
*/
|
||||
@@ -18,8 +22,10 @@ import { metresBetween, whenIdle } from './view.js';
|
||||
export const MESH_CELL_DEG = 0.0001;
|
||||
/** Cells farther than this from the point under the camera are not probed. */
|
||||
export const MESH_SAMPLE_RADIUS_M = 900;
|
||||
/** Main-thread time spent probing per idle slice. */
|
||||
/** Main-thread time per idle slice, taking requests in and ranking included. */
|
||||
export const MESH_SAMPLE_BUDGET_MS = 6;
|
||||
/** The queue is re-ranked once the camera has moved this far, in metres. */
|
||||
export const MESH_RERANK_M = 100;
|
||||
/** A cell whose probe missed is tried again after this long. */
|
||||
export const MESH_MISS_RETRY_MS = 8000;
|
||||
/** Cached cells (and remembered misses) before they are dropped and refilled. */
|
||||
@@ -28,7 +34,52 @@ const MESH_CACHE_MAX = 80_000;
|
||||
const MESH_NOTIFY_MS = 700;
|
||||
|
||||
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
|
||||
const keyOf = (lon, lat) => `${cellOf(lon)},${cellOf(lat)}`;
|
||||
|
||||
/**
|
||||
* The cache key of the mesh cell holding a point, one per ~11 m cell: a
|
||||
* number (exact well within 2^53), as a string key would be built per point.
|
||||
*/
|
||||
export const meshCellKey = (lon, lat) =>
|
||||
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
|
||||
|
||||
/** Add a queued cell to a min-heap on `distance`. */
|
||||
function heapPush(heap, cell) {
|
||||
let i = heap.push(cell) - 1;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (heap[parent].distance <= cell.distance) break;
|
||||
heap[i] = heap[parent];
|
||||
i = parent;
|
||||
}
|
||||
heap[i] = cell;
|
||||
}
|
||||
|
||||
/** Restore the heap below `i` after its cell was replaced. */
|
||||
function siftDown(heap, i) {
|
||||
const cell = heap[i];
|
||||
const n = heap.length;
|
||||
for (;;) {
|
||||
let child = 2 * i + 1;
|
||||
if (child >= n) break;
|
||||
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
|
||||
child++;
|
||||
if (heap[child].distance >= cell.distance) break;
|
||||
heap[i] = heap[child];
|
||||
i = child;
|
||||
}
|
||||
heap[i] = cell;
|
||||
}
|
||||
|
||||
/** Take the nearest cell off a min-heap. */
|
||||
function heapPop(heap) {
|
||||
const top = heap[0];
|
||||
const last = heap.pop();
|
||||
if (heap.length) {
|
||||
heap[0] = last;
|
||||
siftDown(heap, 0);
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
|
||||
function overlays(scene) {
|
||||
@@ -42,13 +93,14 @@ function overlays(scene) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number}} options
|
||||
* @param {{getViewer: () => object|null, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
||||
*/
|
||||
export function createMeshSampler({
|
||||
getViewer,
|
||||
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
||||
radiusM = MESH_SAMPLE_RADIUS_M,
|
||||
cacheMax = MESH_CACHE_MAX,
|
||||
now = () => performance.now(),
|
||||
}) {
|
||||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
||||
const heights = new Map();
|
||||
@@ -58,8 +110,16 @@ export function createMeshSampler({
|
||||
* height cache, so the map stays bounded.
|
||||
*/
|
||||
const misses = new Map();
|
||||
/** cell key → {lon, lat} cell centre waiting to be probed. */
|
||||
/** cell key → queued cell, so a cell is queued once. */
|
||||
const wanted = new Map();
|
||||
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
|
||||
let queue = [];
|
||||
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
|
||||
let intake = [];
|
||||
/** Where the camera was when the queue was ranked. */
|
||||
let rankedFrom = null;
|
||||
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
|
||||
let probeMs = 1;
|
||||
const listeners = new Set();
|
||||
let enabled = false;
|
||||
let running = false;
|
||||
@@ -68,32 +128,90 @@ export function createMeshSampler({
|
||||
|
||||
/** The sampled mesh height for a point, or undefined when not sampled. */
|
||||
function meshAt(lon, lat) {
|
||||
return heights.get(keyOf(lon, lat));
|
||||
return heights.get(meshCellKey(lon, lat));
|
||||
}
|
||||
|
||||
/** Queue the cells under these [lon, lat] points for probing. */
|
||||
/** The point under the camera, or null. */
|
||||
function cameraCentre() {
|
||||
const carto = getViewer()?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the cells under these [lon, lat] points (read later, so do not
|
||||
* change the list). Nothing is done here: idle slices take requests in
|
||||
* within their budget, and drop cells out of range of where the camera was
|
||||
* when asked; callers ask again once it moves.
|
||||
*/
|
||||
function request(points) {
|
||||
if (!enabled) return;
|
||||
const now = Date.now();
|
||||
for (const [lon, lat] of points) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const key = keyOf(lon, lat);
|
||||
if (heights.has(key) || wanted.has(key)) continue;
|
||||
const retryAt = misses.get(key);
|
||||
if (retryAt !== undefined) {
|
||||
if (retryAt > now) continue;
|
||||
misses.delete(key);
|
||||
}
|
||||
wanted.set(key, {
|
||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||
});
|
||||
}
|
||||
if (!enabled || !points?.length) return;
|
||||
const centre = cameraCentre();
|
||||
if (!centre) return;
|
||||
intake.push({ points, next: 0, centre });
|
||||
schedule();
|
||||
}
|
||||
|
||||
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
|
||||
function takeIn(until) {
|
||||
const time = Date.now();
|
||||
while (intake.length) {
|
||||
const pending = intake[0];
|
||||
const { points, centre } = pending;
|
||||
while (pending.next < points.length) {
|
||||
if ((pending.next & 255) === 255 && now() > until) return;
|
||||
const [lon, lat] = points[pending.next++];
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const key = meshCellKey(lon, lat);
|
||||
if (heights.has(key) || wanted.has(key)) continue;
|
||||
const cell = {
|
||||
key,
|
||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||
distance: 0,
|
||||
};
|
||||
if (metresBetween(cell, centre) > radiusM) continue;
|
||||
const retryAt = misses.get(key);
|
||||
if (retryAt !== undefined) {
|
||||
if (retryAt > time) continue;
|
||||
misses.delete(key);
|
||||
}
|
||||
cell.distance = metresBetween(cell, rankedFrom);
|
||||
wanted.set(key, cell);
|
||||
heapPush(queue, cell);
|
||||
}
|
||||
intake.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the queue from where the camera is now, dropping cells it has left
|
||||
* out of range: one linear heapify, run only after the camera has moved.
|
||||
*/
|
||||
function rerank(centre) {
|
||||
rankedFrom = centre;
|
||||
const kept = [];
|
||||
for (const cell of queue) {
|
||||
cell.distance = metresBetween(cell, centre);
|
||||
if (cell.distance > radiusM) wanted.delete(cell.key);
|
||||
else kept.push(cell);
|
||||
}
|
||||
queue = kept;
|
||||
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
intake = [];
|
||||
wanted.clear();
|
||||
queue = [];
|
||||
rankedFrom = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (running || !enabled || !wanted.size) return;
|
||||
if (running || !enabled || !(queue.length || intake.length)) return;
|
||||
running = true;
|
||||
whenIdle(step, 300);
|
||||
}
|
||||
@@ -113,32 +231,38 @@ export function createMeshSampler({
|
||||
|
||||
function step(deadline) {
|
||||
running = false;
|
||||
// The slice starts now: taking requests in and ranking count too.
|
||||
const started = now();
|
||||
const until =
|
||||
started +
|
||||
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
|
||||
const scene = getViewer()?.scene;
|
||||
const carto = getViewer()?.camera?.positionCartographic;
|
||||
if (!enabled || !scene?.sampleHeightSupported || !carto) return;
|
||||
const centre = {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
// Nearest first; cells out of range are dropped (they stay bare earth).
|
||||
const order = [];
|
||||
for (const [key, point] of wanted) {
|
||||
const distance = metresBetween(point, centre);
|
||||
if (distance > radiusM) wanted.delete(key);
|
||||
else order.push([distance, key, point]);
|
||||
const centre = cameraCentre();
|
||||
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
|
||||
// Nearest first, ranked from where the camera was; out-of-range cells
|
||||
// are dropped (they stay bare earth).
|
||||
let worked = false;
|
||||
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
|
||||
rerank(centre);
|
||||
worked = true;
|
||||
}
|
||||
if (intake.length) {
|
||||
// Leave room for a probe, so a stream of requests cannot starve them.
|
||||
takeIn(until - probeMs);
|
||||
worked = true;
|
||||
}
|
||||
order.sort((a, b) => a[0] - b[0]);
|
||||
const slice = Math.max(
|
||||
1,
|
||||
Math.min(budgetMs * 2, deadline?.timeRemaining?.() ?? budgetMs),
|
||||
);
|
||||
const until = performance.now() + Math.min(budgetMs, slice);
|
||||
const exclude = overlays(scene);
|
||||
let probed = 0;
|
||||
for (const [, key, { lon, lat }] of order) {
|
||||
if (probed && performance.now() > until) break;
|
||||
while (queue.length) {
|
||||
// Stop before a probe would overrun the slice. A slice that did
|
||||
// nothing else always probes once, so the queue drains.
|
||||
if ((probed || worked) && now() + probeMs > until) break;
|
||||
const { key, lon, lat } = heapPop(queue);
|
||||
wanted.delete(key);
|
||||
// Ranked from up to MESH_RERANK_M away: check the range from here.
|
||||
if (metresBetween({ lon, lat }, centre) > radiusM) continue;
|
||||
probed++;
|
||||
const probeStarted = now();
|
||||
let height;
|
||||
try {
|
||||
height = scene.sampleHeight(
|
||||
@@ -148,6 +272,7 @@ export function createMeshSampler({
|
||||
} catch {
|
||||
height = undefined;
|
||||
}
|
||||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
||||
if (!Number.isFinite(height)) {
|
||||
if (misses.size >= cacheMax) misses.clear();
|
||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
||||
@@ -172,7 +297,7 @@ export function createMeshSampler({
|
||||
/** Probe only while overlays are cast (Google 3D at street zoom). */
|
||||
function setEnabled(on) {
|
||||
enabled = on === true;
|
||||
if (!enabled) wanted.clear();
|
||||
if (!enabled) clearQueue();
|
||||
else schedule();
|
||||
}
|
||||
|
||||
@@ -185,7 +310,7 @@ export function createMeshSampler({
|
||||
function destroy() {
|
||||
enabled = false;
|
||||
forget();
|
||||
wanted.clear();
|
||||
clearQueue();
|
||||
listeners.clear();
|
||||
clearTimeout(notifyTimer);
|
||||
notifyTimer = null;
|
||||
|
||||
Reference in new issue
Block a user