Mapv10: per-frame coherent LOD selection with priority+cancellation streaming
Status: SUPERSEDED by docs/performance-and-streaming-hardening-spec.md §7.3 (dual SSE upgrade)
This spec captures the original frame-coherent selection model. It remains
authoritative on the deletion of the event-driven debounce state machine and
on the VisibilitySet / TileStreamingPlanner / RenderResolver boundary
shape. The screen-space-error policy described here — a single aggregated
SSE budget — has been replaced. The shipped policy is the dual SSE upgrade
documented in docs/performance-and-streaming-hardening-spec.md §7.3:
SSE telemetry and gating now split by role × metric into the four-way
primaryGeometricSsePx, primaryRasterSsePx, underlayGeometricSsePx,
underlayRasterSsePx gate vocabulary (viewer/src/scenarios/scenarioTypes.ts,
evaluated by viewer/scripts/scenario-gates.mjs). Wave 2 additionally added
the structural-parent walk plus MAX_ANCESTOR_Z_DISTANCE = 1 and the
isFullyResident AND-gate (viewer/src/renderer/lod/RenderResolver.ts).
Do not cite the SSE numbers below as live policy. The body is preserved
verbatim for provenance.
This spec replaces the event-driven, temporally-debounced selection state machine in Mapv10ThreeRenderer with a frame-coherent, pure-function-of-camera selection model and upgrades TileRequestScheduler from FIFO to a per-frame priority queue with explicit stale-key cancellation. The DETAIL_SELECTION_SETTLE_MS debounce, the queued-selection state machine, the TERRAIN_SELECTION_CHECK_INTERVAL_MS throttle, and recordLodSwitch oscillation hysteresis are all deleted.
1. Final architecture
Frame model
The animate loop becomes the single entry point for selection. Every camera-mutating path (OrbitControls drag/wheel/touch, keyboard nav, frameWorld, frameSelection, frameRun, resize) only mutates camera state and sets a single dirty bit; it never directly invokes selection or tile loading. A single per-frame tickFrame(now) body, called from the rAF loop, runs:
- integrate keyboard input
controls.update()(damping)- read+clear the frame dirty bit, decide whether this frame must re-select
- compute
VisibilitySet(pure function of camera + tile pyramid + viewport) - apply
VisibilitySettoTileStreamingPlanner, which rebuilds the priority queue and cancels stale requests - commit any newly-resident tiles into the scene (synchronous; no awaiting inside the frame)
- update labels + fade animations + frame budget drain
- render
- emit camera event (throttled)
- decide whether to request another frame
Because VisibilitySet is pure and the frame loop runs continuously while the system is non-idle, the selection is automatically frame-coherent: the tile set the renderer wants matches what the camera is currently looking at, frame-for-frame.
Primitive: VisibilitySet (per-frame value object)
Owner: produced by selectVisibilitySet(snapshot) — a free function in a new module viewer/src/renderer/lod/VisibilitySet.ts. Stateless. Inputs: a CameraSnapshot (camera matrices, target, viewport size, focal pixels), the run's tilePyramid, the run's meshTiles slot table, the world bounds. Outputs:
interface VisibilitySet {
frameId: number; // monotonically increasing per tick
primaryZ: number; // selected LOD for the focal region
primary: ActiveTileSlot[]; // primary-LOD tiles intersecting the enter footprint
underlay: ActiveTileSlot[]; // ancestor-LOD coverage (z=primary-1, plus z=0)
priorityByKey: Map<string, number>; // tileKey -> screen-priority score (higher = more urgent)
ssePixelsByKey: Map<string, number>;// per-tile screen error (for telemetry/probe)
signature: string; // canonical selection-content key
}
The selection is a pure function of the snapshot; identical snapshots always produce identical sets. There is no cross-frame state inside the function (the previous-frame "kicking" hysteresis lives elsewhere — see RenderResolver).
LOD selection inside this function is the AAA-correct screen-space-error rule: for each candidate LOD z, compute the projected tile width in pixels at the focal distance. The smallest z whose projectedTileWidthPx >= LOD_TARGET_TILE_SCREEN_PX wins (pick deeper if it crosses the threshold). The LOD_SWITCH_UP_PX / LOD_SWITCH_DOWN_PX hysteresis bands and the recordLodSwitch oscillation counter are deleted. Hysteresis at the LOD transition is no longer needed because the visual-stability problem the bands were solving (rapid swap-back during camera deceleration) is now solved by the RenderResolver ancestor-fallback rule (see below) — the renderer simply shows the deeper LOD as soon as it's resident, and continues to show ancestor coverage until then. Pure-function selection means the LOD value only changes when the camera distance changes; with damping, that decay is monotonic, so even the original "oscillation" risk is gone at its source.
The primary-tile footprint uses the existing primaryLodSelectionBounds math (focal-pixel half-extent × camera distance), but with one set of margins instead of two: VIEW_FOOTPRINT_TILE_MARGIN = 0.6 (see Open Questions §8.2). The VIEW_FOOTPRINT_ENTER_TILE_MARGIN / VIEW_FOOTPRINT_RETIRE_TILE_MARGIN pair was a workaround for the queued-commit hysteresis and is no longer needed.
Primitive: TileStreamingPlanner (per-frame imperative shell)
Owner: new class viewer/src/renderer/lod/TileStreamingPlanner.ts. State: holds references to TerrainTileCache, RuntimeTileCache, MeshAssetCache ×3, TileRequestScheduler, and the run baseUrl/manifests. Holds no mutable selection state of its own. Method: apply(set: VisibilitySet): PlannedSelection.
apply performs, synchronously:
- Build the canonical request set — terrain mesh keys + runtime tile keys + auxiliary asset keys derived from
set.primary∪set.underlay, each tagged with the priority fromset.priorityByKey. - Hand the canonical set to
TileRequestScheduler.reconcile(frameId, requests, planner)(see below). The scheduler diffs against the previous frame's request set, cancels any in-flight or queued request whose key is no longer present, enqueues the new keys with current priorities, and updates priorities of carried-over keys. - Read the current cache
loadedmaps and return aPlannedSelectiondescribing whichActiveTileSlots are renderable now (mesh available), and which are still pending (mesh missing). - Trigger LRU pruning by passing the union of (resident-and-needed ∪ recently-needed) as the protect set. This replaces
evictOutsideActiveSetwith a planner-driven sweep that considers underlay tiles part of the protect set even when they're below the primary's screen-coverage threshold.
apply does not await anything. Tile loads happen in the background; their resolution updates the cache loaded maps. The next tickFrame call rebuilds PlannedSelection and notices the new residents.
Primitive: TileRequestScheduler (rewritten — priority queue with reconcile semantics)
Owner: viewer/src/renderer/TileCache.ts. The class keeps its name and concurrency cap (MAX_IN_FLIGHT_TILE_REQUESTS = 24) but its API changes to support a per-frame reconciliation pattern.
State:
- maxInFlight, peakInFlight, inFlight, byHostInFlight (Map<host, number>)
- queue: PriorityHeap<RequestSlot> // binary max-heap on priority
- inflight: Map<requestKey, RequestSlot>
- canceled, requested totals per family (telemetry only)
interface RequestSlot {
requestKey: string; // family-qualified key (e.g. "terrain:meshKey")
priority: number; // updated each frame
signal: AbortSignal; // AbortController owned by the scheduler
controller: AbortController;
start: () => Promise<void>; // runs the actual fetch+decode (already wrapped to honor signal)
resolve: () => void; // called when the fetch completes
reject: (e: Error) => void;
enqueuedAtFrame: number;
family: "terrain" | "runtime" | "water" | "border" | "route";
host: string; // URL.host of the asset, for per-host concurrency cap
}
API:
enqueue(opts: {
requestKey: string;
family: RequestSlot["family"];
url: string;
priority: number;
start: (signal: AbortSignal) => Promise<void>;
}): { signal: AbortSignal; promise: Promise<void> }
reconcile(frameId: number, desired: Iterable<{ requestKey: string; priority: number }>): void
// 1. For each desired key already in `inflight` or `queue`: update priority; resort heap.
// 2. For each desired key not present: caller-side, will call enqueue immediately after.
// 3. For each currently-in-flight or queued key not in `desired`: abort the controller (drops it
// from inflight on rejection; from queue immediately).
// 4. Re-sort the heap if any priorities changed.
channels(): RequestSchedulerStats
// existing fields + added:
// priorityP50, priorityP95 // distribution of currently-in-flight priorities
// reconcileCancellations // total slots aborted by reconcile
// coalescedHits // calls to enqueue for already-inflight keys
Coalescing: enqueue checks inflight and queue for an existing entry by requestKey first. If present, it returns the existing AbortSignal+promise. The cache layers (TerrainTileCache, etc.) already de-duplicate by their own pending map; this gives the scheduler symmetry so it doesn't trigger duplicate fetches even if a cache layer asks for something already started.
Per-host concurrency cap: enforced via byHostInFlight. Default maxPerHost = 8 (fits within HTTP/1.1 limits and below the HTTP/2 stream cap). Overridable in the constructor for tests. When the heap top would exceed either cap (global or per-host), it sits in the heap until a slot frees.
Cancellation propagation: each RequestSlot owns an AbortController. The scheduler aborts that controller. Downstream:
TerrainTileCache.loadTiles/MeshAssetCache.loadAssets/RuntimeTileCache.loadTilesalready pass an externalAbortSignalto the cache. This signal is now created by the scheduler per-tile, not per-selection.- The cache's
pendingpromise rejects withAbortError. The cache's existingtry/catch (signal.aborted)branch already cleans uppendingand increments thecanceledcounter. - The decoder
WorkerPool.dispatchhonors the signal: aborting the signal posts{ kind: "abort", requestId }to the worker, which interrupts the in-flightfetch()and rejects the pending promise. This contract is unchanged; it works with both the old per-selection signal and the new per-tile signal.
Primitive: RenderResolver (forward-looking visual-transition state, the only legitimate cross-frame state)
Owner: new class viewer/src/renderer/lod/RenderResolver.ts. State: previousRenderedKeys: Set<string>. Method: resolve(plan: PlannedSelection, caches: SceneCacheView): RenderCommit.
This is the AAA-correct ancestor-fallback rule (Cesium's ancestorMeetsSse): for every primary tile in the VisibilitySet, prefer to render the primary if it's resident; otherwise climb the underlay chain (already in set.underlay) and render the deepest resident ancestor that covers the primary's footprint. Tiles in previousRenderedKeys are kept visible until their replacement is fully resident — this is the "kicking" pattern. The single piece of cross-frame state is previousRenderedKeys, updated post-render.
interface RenderCommit {
terrainSlots: TerrainRenderAsset[]; // every tile in here has a loaded mesh
runtimeTilesForColors: LoadedRuntimeTile[];
primaryRuntimeTiles: LoadedRuntimeTile[];
auxiliaryReady: boolean; // true if every aux asset for current primaries is resident
notYetResident: string[]; // primary tile keys still loading; for telemetry
}
Mapv10ThreeRenderer.rebuildTerrainPass(commit.terrainSlots) and rebuildAuxiliaryMeshLayers(...) are called every frame the commit changes. Equality check against the previous commit's signature avoids re-uploading geometry when nothing has changed.
Primitive: FrameDirtyBit and FrameTickGovernor
Owner: state on Mapv10ThreeRenderer. Three booleans:
- frameDirty: boolean // any camera/control/selection-relevant input changed
- pendingLoads: boolean // scheduler.inFlight + scheduler.queue.size > 0
- fadesActive: boolean // any label fade in flight, or transition in progress
The animate body always reads frameDirty at the top, clears it, and proceeds. The decision to schedule the next rAF tick is made post-render:
const shouldKeepTicking =
frameDirty // unlikely after the read+clear, but possible if events landed during the frame
|| controls.dampingActive() // see §4 for how this is exposed
|| pendingLoads // tiles still streaming
|| fadesActive // labels mid-fade
|| keyboardActive // any nav key currently held
|| frameBudget.totalPending() > 0; // queued GPU work
if (shouldKeepTicking) requestAnimationFrame(tickFrame);
Note: this implements true on-demand rendering. The current code re-arms requestAnimationFrame unconditionally. The new model parks the loop when the system is fully idle, which removes the entire problem of "but the camera moved in between frames" — when there's nothing to do, there's also nothing that could move.
controls.dampingActive() is implemented as: read OrbitControls's damping target on every input event, store the last camera signature when input ended, and compare ||camera.position - lastTarget|| against an epsilon. (OrbitControls itself has no public "damping settled" API.) Implementation lives in a small wrapper alongside the planner.
All sources that mutate camera state must call markFrameDirty():
- OrbitControls listeners ('start', 'change', 'end')
- keydown / keyup for navigation keys
frameWorld,frameSelection,frameRun,resize- Mode/layer state changes that require reselection (e.g.
setLayerStatetoggling terrain off → cancel pending; toggling on → re-mark dirty) - The scheduler emits a
loadedevent when a tile becomes resident, which marks dirty so the next frame can promote it fromnotYetResidentinto the rendered set.
Where each primitive runs in the frame
tickFrame(now):
recordFrameSample(now - lastFrameAt)
lastFrameAt = now
applyKeyboardNavigation(deltaSeconds)
controls.update()
markCameraDirtyIfMoved() // residual safety: catches OrbitControls integration that bypassed the listeners
updateFogForCamera()
if frameDirty:
frameDirty = false
snapshot = buildCameraSnapshot()
set = selectVisibilitySet(snapshot, run, viewport)
plan = planner.apply(set) // updates scheduler, may trigger background fetches
commit = resolver.resolve(plan, caches)
if commit.signature != lastCommitSignature:
rebuildTerrainPass(commit.terrainSlots)
rebuildTileVectorLayers(commit.primaryRuntimeTiles)
rebuildAuxiliaryMeshLayers(...) // only when aux differs
lastCommitSignature = commit.signature
coverageTransitionsCommitted += 1
updateLabelVisibility()
advanceFadeAnimations(now)
drained = frameBudget.drain(now)
renderer.render(scene, camera)
emitCameraEvent() // still throttled
scheduleNextFrameIfNeeded()
When a load completes, the cache invokes a callback the planner registered (onTileLoaded(family, key)), which calls markFrameDirty(). The very next frame promotes the tile from notYetResident to rendered.
2. Deletion list
Group A — Temporal debouncer:
- Constants
DETAIL_SELECTION_SETTLE_MS = 140andTERRAIN_SELECTION_CHECK_INTERVAL_MS = 80inMapv10ThreeRenderer.ts. - Fields
queuedTerrainSelection,queuedTerrainSelectionReadyAt,lastTerrainSelectionCheckAt,oscillationCount, andlastLodSwitchAtinMapv10ThreeRenderer.ts. - All mutations of the temporal-debouncer fields.
- Method
recordLodSwitch(nextLevel: number)— entirely deleted. - The
if (candidate > activeLodLevel) { ... LOD_SWITCH_UP_PX ... }andif (candidate < activeLodLevel) { ... LOD_SWITCH_DOWN_PX ... }branches insideselectLodForDistance. Replaced (see §3) with the pure SSE rule. - Constants
LOD_SWITCH_UP_PX = 220andLOD_SWITCH_DOWN_PX = 125— no longer referenced. - Field
activeLodLeveland all writes. The LOD value is now a per-frame derived value onVisibilitySet, not class state. - The
queuedSelectionReadyblock inanimate— entirely replaced. - DebugState fields
queuedTerrainSelectionandoscillationCount— removed from the public debug object. (The verifier's existingoscillationCount === 0assertion must be deleted in lockstep — see §6.)
Group B — Two-margin enter/retire bounds:
- Constant
VIEW_FOOTPRINT_RETIRE_TILE_MARGIN = 1.15. - Rename
VIEW_FOOTPRINT_ENTER_TILE_MARGIN→VIEW_FOOTPRINT_TILE_MARGINand keep one value (0.6, see §8.2). All call sites inselectVisibleTileSlotsandselectUnderlayTileSlotscollapse to one bounds call per pyramid level.
Group C — Imperative selection in event handlers:
- The body of
frameSelectionloses its tail callvoid this.updateTerrainTileSelection(). Replaced with a singlemarkFrameDirty()(see §4). frameWorld: same treatment, the referenced symbol deleted.loadRun:await this.updateTerrainTileSelection(true)is replaced byawaitInitialResidency()which waits on the scheduler/cache to drain by pollingframeDirty || pendingLoadsfor the first frame's commit. (The originalawait ... force=trueexists so the renderer is "ready" before the run-loaded event fires.)- The entire
private async updateTerrainTileSelection(force = false): Promise<void>method is deleted. Its behavior is replaced byselectVisibilitySet+TileStreamingPlanner.apply+RenderResolver.resolve, all called from the per-frame body. - Method
ensureAuxiliarySelectionandloadAuxiliaryMeshSelection— deleted. Auxiliary selection is now driven by the same VisibilitySet, scheduled viaTileStreamingPlanner.apply, and committed byRenderResolver.resolve. TheauxiliaryGapCountcounter and its check are deleted; the resolver guarantees no gap by holding the previous aux set until the new one is fully resident. - Field
private auxiliarySelectionAbort: AbortController | null = null— deleted; cancellation is now per-tile in the scheduler. - Field
private terrainSelectionAbort: AbortController | null = null— deleted; same reason. - Field
private pendingTerrainSelection = "",private activeAuxiliarySelection = "",private pendingAuxiliarySelection = ""— deleted. The planner derives "pending" from the scheduler channels; "active" is the resolver'spreviousRenderedKeys(terrain + aux). - Fields
private activeTerrainSelection = ""andprivate activeTerrainUnderlaySelection = ""— deleted; the rendered key set is on the resolver. - Method
activeSlotsForZoom— deleted. It existed to cross-reference theactiveTerrainSelectionstring against the run; the resolver tracks tiles by reference, not by joined-string keys. - Method
selectTerrainCoverage— deleted. Its body is folded intoselectVisibilitySet. - The
auxiliaryGapCountfield and writes — deleted. - The
parentDroppedBeforeChildReadyfield and writes — deleted. The resolver's ancestor fallback guarantees this counter is structurally zero.
Group D — On-demand frame loop:
- The unconditional
requestAnimationFrame(this.animate)at the top ofanimate— replaced by the post-renderscheduleNextFrameIfNeeded()call. - The
this.animate()call (inmount) — replaced bymarkFrameDirty(); scheduleNextFrameIfNeeded().
Sequencing (leaves to roots): Group A → Group B → Group C → Group D. Group A deletions stand alone. Group B is mechanical rename. Group C requires Groups A+B done first because it deletes the consumer of the queue/oscillation state. Group D requires C done first because the loop body shape changes when selection moves into a per-frame phase.
3. Addition list
viewer/src/renderer/lod/VisibilitySet.ts (new file)
export interface CameraSnapshot { ... } // immutable view into camera + viewport
export interface ActiveTileSlot { ... } // moved from Mapv10ThreeRenderer.ts
export interface VisibilitySet { ... } // shape from §1
export interface SelectionInputs {
run: LoadedRun;
worldBounds: Bounds;
metrics: SceneMetrics;
}
export function buildCameraSnapshot(camera: PerspectiveCamera, controls: OrbitControls,
viewportWidth: number, viewportHeight: number, frameId: number): CameraSnapshot;
export function selectVisibilitySet(snap: CameraSnapshot, inputs: SelectionInputs): VisibilitySet;
Contract: selectVisibilitySet is a pure function. Identical inputs always produce identical outputs (including identical signature). It computes:
primaryZvia the SSE rule (no hysteresis): pick the deepest level whoseprojectedTileWidthPx >= LOD_TARGET_TILE_SCREEN_PX. IfprimaryZ === 0, the primary set is the whole z=0 grid (small) and the underlay set is empty.primaryvia the focal-pixel footprint at z=primaryZ, expanded byVIEW_FOOTPRINT_TILE_MARGIN.underlayforprimaryZ > 0via the same footprint expanded again, sampled atz=primaryZ-1andz=0. The two-level (immediate parent + root) underlay choice is preserved from the existing code; it gives both a high-quality fallback and a guaranteed catch-all.priorityByKey: per Cesium's formula adapted to mapv10:Higher = more urgent. Defaults:priority(slot) =SCREEN_AREA_WEIGHT * screenAreaPx(slot)- DEPTH_PENALTY_PER_LEVEL * (primaryZ - slot.tile.z) // primaries score higher than underlays+ FOREGROUND_BIAS * max(0, 1 - distance(slot.center, focal) / focalRadius)SCREEN_AREA_WEIGHT = 1.0,DEPTH_PENALTY_PER_LEVEL = 250(one level under primary roughly equals 250 px² of coverage),FOREGROUND_BIAS = 200. These are tunable; the formula is the architecture, the constants are not.signature: concatenation of sorted primary keys + ":" + sorted underlay keys + ":z" + primaryZ. Used by the resolver to short-circuit.
Callers: Mapv10ThreeRenderer.tickFrame only. No other code reaches into this module.
viewer/src/renderer/lod/TileStreamingPlanner.ts (new file)
export interface PlannedSelection {
set: VisibilitySet;
terrainKeys: string[];
runtimeKeys: string[];
auxiliaryKeys: { water: string[]; border: string[]; route: string[] };
notYetResident: string[]; // primary tile keys whose mesh+runtime aren't both loaded
auxiliaryReady: boolean;
}
export class TileStreamingPlanner {
constructor(deps: {
terrainTileCache: TerrainTileCache;
runtimeTileCache: RuntimeTileCache;
waterMeshCache: MeshAssetCache;
borderMeshCache: MeshAssetCache;
routeMeshCache: MeshAssetCache;
scheduler: TileRequestScheduler;
});
apply(set: VisibilitySet): PlannedSelection;
pendingCount(): number;
dispose(): void;
}
Contract: apply is synchronous. It computes the canonical request set, calls scheduler.reconcile(set.frameId, requests) (cancels stale, updates priorities), then triggers the cache layers to begin fetching anything new. Returns immediately with whatever's currently resident plus the not-yet-resident tail.
Internally apply calls terrainTileCache.requestTiles(terrainKeys, priorityByKey), runtimeTileCache.requestTiles(runtimeKeys, priorityByKey), etc. — these are new methods on the caches (see §4) that replace the awaited loadTiles API. They schedule via the scheduler with a per-tile signal owned by the scheduler (not by a selection-wide AbortController).
viewer/src/renderer/lod/RenderResolver.ts (new file)
export interface RenderCommit { ... } // shape from §1
export class RenderResolver {
resolve(plan: PlannedSelection, caches: SceneCacheView): RenderCommit;
notifyRendered(commit: RenderCommit): void; // called post-render to update previousRenderedKeys
dispose(): void;
}
Contract: builds the actual render set per the ancestor-fallback rule. For each primary slot in plan.set.primary:
- if the primary mesh is resident → use it
- else find the deepest ancestor in
plan.set.underlaywhose mesh is resident and footprint contains the primary - else fall back to the previous frame's rendered key set if it covered this footprint
- else (initial frame, nothing resident) → omit the slot (renderer shows an empty area until the first tile arrives)
The "previous frame's rendered key set" is the kicking pattern; it's the only cross-frame state in the system.
notifyRendered is called by Mapv10ThreeRenderer.tickFrame after renderer.render(), copying the just-rendered key set into previousRenderedKeys.
viewer/src/renderer/TileCache.ts — TileRequestScheduler rewrite (in place)
The class keeps its name, file location, and existing module exports. The rewritten signature replaces the old schedule() API with a request-key-scoped enqueue(...) and reconcile(...) (see §1). This is a breaking change; every cache layer (TerrainTileCache.scheduler.schedule(...), MeshAssetCache.scheduler.schedule(...), RuntimeTileCache.scheduler.schedule(...) ×9 call sites) is rewritten to call enqueue(...) instead.
Heap: a simple binary max-heap of RequestSlot[]. siftUp/siftDown keyed on priority. reconcile traverses both inflight and the heap to update priorities (O(N log N) acceptable; visibility sets are bounded — typically <200 keys). Uses Map<requestKey, RequestSlot> for O(1) coalescing.
viewer/src/renderer/TileCache.ts — new methods on TerrainTileCache and MeshAssetCache
TerrainTileCache.requestTiles(keys: string[], priorityByKey: Map<string, number>): void
TerrainTileCache.onLoaded(callback: (key: string) => void): () => void
MeshAssetCache.requestAssets(keys: string[], priorityByKey: Map<string, number>): void
MeshAssetCache.onLoaded(callback: (key: string) => void): () => void
RuntimeTileCache.requestTiles(keys: string[], priorityByKey: Map<string, number>): void
RuntimeTileCache.onLoaded(callback: (key: string) => void): () => void
requestTiles is fire-and-forget. For each key not in loaded and not in pending, it calls scheduler.enqueue with the priority and a start(signal) closure that resolves the tile and updates the cache's loaded/pending maps + counters. It sets pending.set(key, scheduler-promise).
onLoaded lets the planner subscribe to load events to mark the frame dirty.
The existing loadTiles(tileKeys, signal): Promise<LoadedMeshAsset[]> API is deleted. It awaited a selection-wide signal; the new API takes per-tile signals owned by the scheduler.
viewer/src/renderer/Mapv10ThreeRenderer.ts — new methods on the renderer
private markFrameDirty(): void; // sets frameDirty = true; calls scheduleNextFrameIfNeeded
private scheduleNextFrameIfNeeded(): void; // single rAF reschedule, idempotent (single-pending guard)
private buildCameraSnapshot(): CameraSnapshot;
private tickFrame(now: number): void; // replaces animate
private awaitInitialResidency(): Promise<void>; // used by loadRun; returns when first commit fully ready
Field additions:
private frameDirty = true;
private framePending = false; // single-pending rAF guard
private currentFrameId = 0;
private planner: TileStreamingPlanner | null = null;
private resolver: RenderResolver | null = null;
private lastCommitSignature = "";
private parentDroppedBeforeChildReady — DELETED (resolver enforces invariant)
private auxiliaryGapCount — DELETED (resolver enforces invariant)
DebugState additions:
priorityP50: number;
priorityP95: number;
schedulerCancellations: number;
notYetResidentTerrainKeys: string[]; // tiles in current VisibilitySet that aren't resident yet
visibilitySetSignature: string; // for the new verifier probe
visibilitySetFrameId: number;
DebugState removals: queuedTerrainSelection, oscillationCount, auxiliaryGapCount, parentDroppedBeforeChildReady, pendingTerrainSelection, pendingAuxiliarySelection. The verifier removes the corresponding assertions (see §6).
4. Modification list
Mapv10ThreeRenderer.frameSelection: drop void this.updateTerrainTileSelection() and this.terrainSelectionDirty = true; this.labelLayoutDirty = true;. Replace with this.markFrameDirty();.
Mapv10ThreeRenderer.frameWorld: same treatment.
Mapv10ThreeRenderer.frameRun: drop this.terrainSelectionDirty = true; and this.labelLayoutDirty = true;; add this.markFrameDirty();.
Mapv10ThreeRenderer.resize: drop the two dirty bits; add this.markFrameDirty();. Keep updateLabelVisibility(true).
Mapv10ThreeRenderer.markCameraDirty: rename to markCameraInputDirty (semantically: input changed, dirty for next selection); body becomes this.markFrameDirty();. The this.labelLayoutDirty = true line stays inside markFrameDirty because labels depend on camera too.
Mapv10ThreeRenderer.applyKeyboardNavigation: no body changes; the existing call to this.markCameraDirty() goes through the renamed method.
Mapv10ThreeRenderer.installControls: add controls.addEventListener("change", () => this.markFrameDirty()) and controls.addEventListener("end", () => this.markFrameDirty()). Currently OrbitControls "wakes" the loop only because the loop is unconditional; once the loop is on-demand, these listeners are required.
Mapv10ThreeRenderer.mount: replace this.animate() with this.markFrameDirty(); this.scheduleNextFrameIfNeeded();.
Mapv10ThreeRenderer.dispose: cancelAnimationFrame still required if framePending. Add this.planner?.dispose(); this.resolver?.dispose();.
Mapv10ThreeRenderer.prepareTerrainStreaming: after constructing caches and scheduler, instantiate this.planner = new TileStreamingPlanner({...}) and this.resolver = new RenderResolver(). Subscribe to cache.onLoaded(() => this.markFrameDirty()) for all five cache layers. Drop initialization of all the deleted fields.
Mapv10ThreeRenderer.disposeTerrain: drop initialization of all deleted fields. Add this.planner?.dispose(); this.planner = null; this.resolver?.dispose(); this.resolver = null; this.lastCommitSignature = "";.
Mapv10ThreeRenderer.loadRun: replace await this.updateTerrainTileSelection(true) with await this.awaitInitialResidency(). The new method marks dirty, schedules a frame, and resolves when tickFrame produces a RenderCommit whose notYetResident.length === 0 and auxiliaryReady === true for the initial camera target. It logs an error and rejects on any cache failure (already surfaced via cache promise rejections that the planner forwards).
Mapv10ThreeRenderer.selectLodForDistance: rewritten. The hysteresis branches are removed. Body is:
private selectLodForDistance(distance: number): number {
if (!this.run) return 0;
const levels = [...this.run.tiles.tilePyramid.zoomLevels].sort((a, b) => a.z - b.z);
let best = levels[0]?.z ?? 0;
for (const level of levels) {
if (this.projectedTileWidthPx(level.tileCountX, distance) >= LOD_TARGET_TILE_SCREEN_PX) {
best = level.z;
}
}
return best;
}
This method moves into selectVisibilitySet (it's called from there with the camera distance derived from the snapshot). The renderer no longer needs its own copy.
Mapv10ThreeRenderer.selectVisibleTileSlots and selectUnderlayTileSlots: both move into viewer/src/renderer/lod/VisibilitySet.ts as free functions. The "use the active set as a retire-margin retainer" branch is deleted — that hysteresis was only needed because of the queued-commit race; with per-frame coherence the camera-derived footprint is the truth.
Mapv10ThreeRenderer.animate: renamed to tickFrame(now), body restructured per §1. The unconditional rAF reschedule at the top is removed; the post-render scheduleNextFrameIfNeeded() decides whether to keep ticking.
Mapv10ThreeRenderer.advanceFadeAnimations: no logic change, but the post-loop this.pruneTerrainSceneNodes() and this.pruneAuxiliarySceneNodes() calls move into the tickFrame body to make the per-frame ordering explicit.
Mapv10ThreeRenderer.getDebugState: drop queuedTerrainSelection, oscillationCount, auxiliaryGapCount, parentDroppedBeforeChildReady, pendingTerrainSelection, pendingAuxiliarySelection from both the type and the value. Add the new fields from §3.
TerrainTileCache.loadTile, MeshAssetCache.loadAsset, and RuntimeTileCache.loadTile: rewritten to be invoked via requestTiles/requestAssets rather than awaited. The signal argument is now scheduler-owned per-tile. The try/catch aborts/cleanups stay identical.
TerrainTileCache.evictOutsideActiveSet: kept; the planner now passes the union of (current frame's request set ∪ previous frame's render set) as the protect set so underlay tiles aren't evicted while still rendered.
5. Implementation phasing
Each phase is a self-contained landing that builds and passes the verifier. The verifier's debug-state assertions need to stay green throughout, which constrains the deletion order (tests depending on a field stay until the field's removal lands together with the assertion update).
Phase 1: scheduler rewrite (priority + reconcile + per-tile cancellation)
Files touched: viewer/src/renderer/TileCache.ts, viewer/src/renderer/RuntimeTileCache.ts, viewer/src/renderer/Mapv10ThreeRenderer.ts (only the call sites; the class structure unchanged).
Deliverables:
TileRequestSchedulerrewritten with priority heap, per-host cap, reconcile, per-slot AbortControllers.TerrainTileCache/MeshAssetCache/RuntimeTileCachegainrequestTiles/requestAssets/onLoaded. OldloadTiles/loadAssetscontinue to exist as thin wrappers (awaitrequestTilesthen return resolved promises) so the rest of the code keeps working unchanged.- Priorities are uniformly 1.0 for all requests in this phase (priority queue degenerates to FIFO). This decouples the queue rewrite from the selection rewrite.
Success criteria:
dotnet build SECS.slnpasses.npx tsc --noEmitpasses fromexamples/map/mapv10/viewer/.npm run buildpasses.npm run verify:browser:continentpasses with no behavior change visible to the verifier (priorities are uniform → identical request order to today).- New unit tests for the scheduler: enqueue/dequeue ordering by priority, reconcile cancels stale keys, coalescing returns the same promise for duplicate keys, per-host cap respected.
Phase 2: VisibilitySet + planner + resolver, behind a feature flag
Files touched: 3 new files in viewer/src/renderer/lod/. Mapv10ThreeRenderer.ts adds the new fields, the new methods, and a private boolean private useFrameCoherentSelection = false; switch.
Deliverables:
selectVisibilitySet,TileStreamingPlanner,RenderResolverwritten and unit-tested in isolation.- The renderer's
tickFramemethod is added but not called yet;animatecontinues to run. - Snapshot/golden tests on
selectVisibilitySetpin the LOD selection at 6 representative camera positions (continent overview, province cluster, location detail, mid-zoom, oblique, near-clip).
Success criteria: build + verifier still passing because the new code is dormant.
Phase 3: switch the loop to tickFrame and pure-per-frame selection
Files touched: Mapv10ThreeRenderer.ts (animate replaced, dirty-bit flow wired up, on-demand rAF). Verifier untouched in this phase except for one assertion change below.
Deliverables:
animatedeleted,tickFrameis the only loop body.- Frame dirty bit drives selection; OrbitControls listeners and event handlers wire through
markFrameDirty. useFrameCoherentSelectionflag deleted; the new path is the only path.- DebugState fields
queuedTerrainSelection,oscillationCount,auxiliaryGapCount,parentDroppedBeforeChildReadyremoved from the type and the value. New fields added. - Verifier
verify-browser.mjsdebugStateMatches: removestate.parentDroppedBeforeChildReady === 0,state.auxiliaryGapCount === 0,state.oscillationCount === 0assertions. These invariants are now structural (the resolver guarantees them); the verifier no longer needs to check them.
Success criteria:
- All four build/verify steps pass.
- The verifier observes correct LOD residency at all six camera steps.
- The
passes.terrainandpasses.borderRoutecounts are non-zero throughout, proving the resolver's commit produced render geometry.
Phase 4: priority formula + new verifier probe
Files touched: viewer/src/renderer/lod/VisibilitySet.ts (real priority math), verify-browser.mjs (new probe per §6).
Deliverables:
- Replace uniform-1.0 priorities with the real screen-area + depth-penalty + foreground-bias formula.
- Add the
assertSelectionImmediatelyCoherentprobe (§6).
Success criteria:
- Build + verifier pass, including the new probe asserting that within ~1 frame of
__mapv10FrameWorld, the visibility-set signature is stable and matches the camera target's expected zoom.
Phase 5: cleanup
Files touched: viewer/src/renderer/TileCache.ts (delete the legacy loadTiles/loadAssets wrappers from Phase 1), Mapv10ThreeRenderer.ts (delete selectVisibleTileSlots, selectUnderlayTileSlots, selectTerrainCoverage, activeSlotsForZoom, selectLodForDistance, recordLodSwitch).
Success criteria: same as Phase 4. Source tree leaner.
6. New verifier probe
The defect: a single __mapv10FrameWorld(point, distance) call lands the camera at the target, marks the selection dirty, then queues the result for DETAIL_SELECTION_SETTLE_MS = 140 ms. The animate loop's TERRAIN_SELECTION_CHECK_INTERVAL_MS = 80 ms throttle plus the queue's settle window means up to ~220 ms can elapse before any tile is even requested. waitForDebugState's 15-second polling window is so much larger than this that the test always observes the eventually-correct state.
The fix probe asserts that the visibility set signature is stable within one frame of the camera move and that the planner has dispatched requests immediately (not queued for 140 ms).
Probe target
Add assertSelectionImmediatelyCoherent after runCameraStep in verify-browser.mjs. Call it inside runCameraStep right after the __mapv10FrameWorld page.evaluate call, before the existing waitForDebugState.
What it asserts
- After a single tick of the rAF loop (verifier waits one rAF via page.evaluate),
__mapv10DebugState().visibilitySetFrameId has incremented from the pre-call value.
- The new visibilitySetSignature matches the expected zoom level
(signature starts with primary keys at z=expectedZoom).
- After two more rAF ticks, visibilitySetSignature has NOT changed
(proves the selection is a stable function of camera, not a moving target).
- scheduler.requested has incremented by at least the size of the visibility set
during the same ~3-frame window (proves requests were dispatched, not queued).
- scheduler.peakInFlight > 0 in the same window (proves the scheduler started work
rather than letting requests sit in the heap).
Required __mapv10DebugState fields (added in Phase 3)
visibilitySetFrameId: number // monotonic counter, increments each tickFrame that ran selection
visibilitySetSignature: string // VisibilitySet.signature
notYetResidentTerrainKeys: string[] // keys in the set that aren't resident yet
schedulerCancellations: number // total reconcile-driven cancellations
Pseudocode for the assertion
Insert in verify-browser.mjs between the page.evaluate(__mapv10FrameWorld) and the waitForDebugState call:
async function waitOneFrame(page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
}
async function assertSelectionImmediatelyCoherent(page, expectedZoom, label) {
const before = await page.evaluate(() => window.__mapv10DebugState?.());
await waitOneFrame(page);
const afterFrame1 = await page.evaluate(() => window.__mapv10DebugState?.());
await waitOneFrame(page);
await waitOneFrame(page);
const afterFrame3 = await page.evaluate(() => window.__mapv10DebugState?.());
if (afterFrame1.visibilitySetFrameId <= before.visibilitySetFrameId) {
throw new Error(`${label}: selection did not run within one frame of camera move`);
}
if (!afterFrame1.visibilitySetSignature.startsWith(`primary:terrain.z${expectedZoom}.`) &&
!afterFrame1.visibilitySetSignature.includes(`:z${expectedZoom}|`)) {
throw new Error(`${label}: visibilitySetSignature ${afterFrame1.visibilitySetSignature} does not match z${expectedZoom}`);
}
if (afterFrame3.visibilitySetSignature !== afterFrame1.visibilitySetSignature) {
throw new Error(
`${label}: selection signature unstable across 2 frames; ` +
`${afterFrame1.visibilitySetSignature} -> ${afterFrame3.visibilitySetSignature}`
);
}
const requestsDispatched = (afterFrame3.scheduler?.peakInFlight ?? 0) +
(afterFrame3.scheduler?.requested ?? 0);
const requestsBefore = (before.scheduler?.peakInFlight ?? 0) +
(before.scheduler?.requested ?? 0);
if (requestsDispatched <= requestsBefore && afterFrame1.notYetResidentTerrainKeys.length > 0) {
throw new Error(`${label}: scheduler did not dispatch requests within 3 frames despite needing tiles`);
}
return {
framesToFirstSelection: afterFrame1.visibilitySetFrameId - before.visibilitySetFrameId,
signatureAtFrame1: afterFrame1.visibilitySetSignature,
signatureAtFrame3: afterFrame3.visibilitySetSignature,
requestsDispatched: requestsDispatched - requestsBefore,
notYetResident: afterFrame1.notYetResidentTerrainKeys.length
};
}
Call site at line ~486:
await page.evaluate(({ point, distanceKm }) =>
window.__mapv10FrameWorld?.(point, distanceKm), { point: step.point, distanceKm: step.distanceKm });
const coherence = await assertSelectionImmediatelyCoherent(page, step.expectedZoom, step.label);
const state = await waitForDebugState(page, step.expectedZoom, { ... });
Add coherence to the returned step object so it lands in the verification proof JSON for inspection.
This catches the original defect because under the old DETAIL_SELECTION_SETTLE_MS = 140 ms regime, the first frame sees the selection still queued — visibilitySetFrameId hasn't incremented because no selection ran — and the assertion fails.
7. Risks and migration concerns
User-visible behavior changes
- No more LOD oscillation counter on the debug overlay. Anyone reading that field for diagnosis loses it. The structural guarantee (no oscillation possible because LOD is a pure function of distance, with damping providing monotonic decay) is the replacement.
- Selection commits more frequently during interactive zoom/pan. Today the debouncer suppresses commits during continuous motion; the new model commits every frame the LOD or visible set changes. With the per-frame coherent code path, this is correct — visual quality during motion improves because the renderer always shows the right tiles for the current camera. Cost: more
rebuildTerrainPasscalls. Mitigated by the existing equality check on commit signature (which already exists implicitly via thependingTerrainSelectionguard; the new code makes it explicit vialastCommitSignature). - Render loop parks when idle. Today the loop runs at 60 Hz forever, burning power. The new on-demand model parks when nothing is happening. CPU/GPU usage drops during periods of camera stillness. This is a behavior change visible in DevTools (FPS readout falls to 0 during idle), not a regression — Three.js's official guidance is exactly this pattern. The smoothedFrameMs telemetry still updates from the frames that do run, so the value remains meaningful when there's motion.
- The
requestedcounter on caches grows faster. The old code de-duplicated per-selection; the new code de-duplicates per-tile-key. Net effect: the counter no longer grows with how often the user moves the camera; it grows with how many distinct tiles get visited. The verifier'stileFetchFamilyCounts.terrain >= 1check still passes.
Concurrency edge cases
Aborting a tile request mid-decode in a worker. The audit confirms WorkerPool.dispatch registers the AbortSignal, posts { kind: "abort", requestId } on signal abort, and the worker responds with AbortError which handleResponse treats normally. The new scheduler aborts a per-tile signal — exactly the same shape as the old per-selection signal. The worker has no idea anything changed. Abort semantics are unchanged.
What does change: today, aborting one tile in a selection aborts the entire selection (everything shares one terrainSelectionAbort controller). Tomorrow, only the actually-stale tiles are aborted; in-flight tiles whose keys remain in the new VisibilitySet keep going. This is an improvement (less wasted work during smooth camera motion where most tiles carry over) but it's behaviorally different. The cache's pending map and loadedTotal/canceled counters handle this correctly — the cache's loadTile internals already key on a single tile key, so it sees nothing different from being asked for individual tiles.
Race: load completes after VisibilitySet has changed. The cache resolves the promise; the scene already moved on. Today the old pendingTerrainSelection mismatch check silently drops the result; tomorrow, the cache simply marks the tile as loaded and the next frame's RenderResolver.resolve decides whether to use it. If the tile is no longer in the VisibilitySet, the next planner.apply will evict it via the LRU path. No special handling needed.
Race: loadRun fails because some tiles don't exist for the initial overview. The old code awaited updateTerrainTileSelection(true) and surfaced the error synchronously. The new awaitInitialResidency() must surface the same error. The planner forwards cache rejection via the load-completion callback path; awaitInitialResidency listens for error events on the renderer (already emitted) and rejects on the first one before the initial commit. The verifier's missing-required-product test continues to assert that the manifest-missing-product error reaches __mapv10Ready.errors.
Race: setLayerState toggling terrain visibility off mid-load. Today the toggle simply hides the pass via applyLayerVisibility. Loaded tiles continue to load, then sit unused. Tomorrow: same, but the planner's protect set still contains the (no-longer-rendered-but-still-needed-on-toggle-back) keys, so they're not evicted. Acceptable. If memory pressure is real, a future change can wire setLayerState to influence the priority — not part of this redesign.
Damping settling detection. OrbitControls doesn't expose a public "settled" API. The implementation reads controls.target and camera.position after controls.update() and compares against the post-event-end values; if the deltas are below SETTLED_EPSILON = 1e-4, damping is settled. If a future Three.js release exposes a public API for this, we use it instead. Risk: false-negative (loop parks while damping is still bleeding off in the 5th decimal place) is invisible to the user. False-positive (loop keeps ticking forever) is caught by the verifier indirectly: stale peakInFlight > 0 over too many frames indicates the loop never parked.
Concerns we did NOT address by design
- The per-frame selection cost.
selectVisibilitySetwalks themeshTiles.tilesarray twice per LOD level. With current scales (~160 tiles at z=2, ~640 at z=3) this is sub-millisecond per frame. If a future scale preset exposes a problem, the next architectural pass introduces a 2D quadtree on the tile pyramid. Not done now because the current arrays are short enough that quadtree maintenance overhead would dominate the savings.
8. Open questions
8.1 — Damping settled detection: should we patch OrbitControls or instrument it externally?
Question: OrbitControls integrates damping inside update() and exposes no signal that the residual motion has decayed below threshold. The proposed external compare-against-target approach works but couples the renderer to OrbitControls's internal state through observation rather than contract.
Most likely answer: Instrument externally for now (a 4-line wrapper in Mapv10ThreeRenderer). Reasoning: patching three/examples/jsm/controls/OrbitControls.js creates a maintenance burden against upstream Three.js that's disproportionate to a four-line check. The dampingFactor = 0.12 setting means residual motion decays exponentially with a half-life of ~6 frames; even a generous epsilon parks the loop within 12 frames of the user releasing the mouse. The wasted ~200 ms per drag-release is acceptable compared to the patching cost. If it ever becomes a hot path, switch to a forked OrbitControls in a single PR.
8.2 — Single tile margin or two for primary footprint?
Question: The current code uses two margins (ENTER = 0.35, RETIRE = 1.15) for hysteresis between "tile enters the active set" and "tile is allowed to stay in the active set." The hysteresis was needed because the queued-commit state machine made set membership oscillate between two values. With per-frame coherence, hysteresis at this level is redundant — the resolver's previous-frame ancestor-fallback already handles the visual transition.
Most likely answer: One margin, VIEW_FOOTPRINT_TILE_MARGIN = 0.6 (mid-point of the old enter/retire range). Reasoning: 0.6 gives roughly the same per-frame footprint as the current ENTER = 0.35 extended halfway to RETIRE. This keeps the typical set size unchanged so the cache budget tuning (CACHE_BUDGETS table) remains valid. If the verifier ever shows tiles being needlessly evicted on the edges of the viewport, raise to 0.85; if memory pressure shows in residency telemetry, lower to 0.45. The exact value is a Phase-5 tuning parameter, not part of the architecture.
8.3 — Should priority be packed into a fixed-bit integer (Cesium-style "packed digits") or stay as a float?
Question: Cesium packs priority into a 32-bit integer with explicit bit fields (depth, distance, etc.) so heap comparisons are integer comparisons (cache-friendly, deterministic). The proposed implementation uses a JS number with a weighted sum.
Most likely answer: Stay with weighted-sum float for now. Reasoning: V8 represents JS numbers as IEEE-754 doubles; comparing two doubles is one machine instruction. The packed-digit pattern is a win in C++ where it eliminates branch mispredictions, but in V8 the comparison is the same speed either way. The float formula is also easier to evolve (add a foveation term, add a preload-flight band) without re-laying-out the bits. The Cesium-style packed-digits pattern becomes valuable if and when the heap comparison shows up as hot in profiling, which is unlikely for visibility sets of <500 keys.
8.4 — Do we keep the coverageTransitionsCommitted counter?
Question: The Mapv10ThreeRenderer terrain-selection commit counter increments once per terrain-selection commit. With per-frame commits during motion, it grows much faster. Should it stay?
Most likely answer: Keep, but rename to commitsCount and document it as a per-frame counter rather than a per-camera-event counter. Reasoning: the counter is a useful diagnostic for "am I rebuilding scene geometry this frame or not?" — that semantic is preserved. The verifier doesn't assert anything specific about its value, so growth-rate change doesn't break tests.
Critical Files for Implementation
- /home/herki/dev/github/secs-workspace/examples/map/mapv10/viewer/src/renderer/Mapv10ThreeRenderer.ts
- /home/herki/dev/github/secs-workspace/examples/map/mapv10/viewer/src/renderer/TileCache.ts
- /home/herki/dev/github/secs-workspace/examples/map/mapv10/viewer/src/renderer/RuntimeTileCache.ts
- /home/herki/dev/github/secs-workspace/examples/map/mapv10/viewer/src/workers/WorkerPool.ts
- /home/herki/dev/github/secs-workspace/examples/map/mapv10/viewer/scripts/verify-browser.mjs