Residency budget for sweep-registered progressive LOD leaves.
An ADDITIVE ladder is prefix accumulation: its terminal state is 100% of the
node, on every node, always. That bounds FIRST PAINT — the first rung is
small — but it bounds nothing at rest, and the refinement loop drives every
loader to its last rung with no notion of what the scene can afford.
On the hosted cosmicflows_laniakea demo that is 10 line nodes x ~1.3M
segments = 11.4M segments, and the tab dies with RangeError: Array buffer allocation failed partway up the ladders (#2426). The eager admission gate
in nodes/load-children-concurrently.ts already measures each node against a
budget and serialises the initial loads — but it releases in the finally
around each child's load, and refinement is scheduled later
(lifecycle/load-scene.ts), so by the time the ladders climb, nothing is
holding a budget at all. This gate uses the device heap LIMIT already read
by the cache sizing path; it does not probe current usedJSHeapSize.
This is a REFUSAL gate, never an evictor. It declines to load MORE; it never
discards what is already committed. A node that hits the ceiling simply stops
at the rung it reached and stays there — a legible partial scene rather than
a dead tab. Legible to a HUMAN, that is: which nodes won the remaining
headroom is not deterministic, so the element counts of a stopped scene are
not a property of the store and nothing downstream may compare them across
builds. That is why RefinementResidencyReporter also RECORDS every
refusal for RefinementResidencyStop, which the debug snapshot carries
and core/app/debug/capture-readiness.ts refuses on (#2508) — a console
warning is not something a harness can read. Only loaders registered in the
four refinement sweep maps are
covered; lazy lod_group levels are outside those maps and outside this
accounting. The budget uses the eager gate's working-set calculation, including
its EAGER_WORKING_SET_CAP_BYTES ceiling: sufficiently large desktop heaps all
resolve to that same cap rather than scaling without bound. The GPU buffer
pool instead takes the full shared non-cache remainder up to its own 2 GB
ceiling and covers some of the same renderer element-row bytes. These remain
independent ceilings, not additive reservations; eager in-flight bytes,
pooled GPU bytes, and refinement residency may coexist transiently.
Decoded payload bytes are measured from the typed arrays. Renderer element
rows are derived from each geometry's authoritative layout constant, because
those allocations are not present in the decoded payload. The renderer's two
Uint32 ordering buffers (8 B/element) are intentionally omitted, as is the
separately bounded slice cache. The next pass is estimated from the mean
accounted rung so far — see estimateNextRungBytes. An admitted loader
receives a per-pass share of the remaining headroom across tracked,
non-declined paths, with enough allowance for its estimated next rung, and
stops after the first level that spends it. The tracked-set denominator
deliberately includes completed or currently filtered paths from the raw
sweep maps, so it may under-grant relative to the loaders currently offered.
This is not scene-wide round-robin fairness: geometry phases run sequentially,
and an earlier phase can consume the ceiling before a later phase is offered.
The allowance still prevents a warmed cache from consuming the rest of the
ladder under one admission. During a fold/commit, the previous cumulative CPU
payload can remain reachable while the replacement is allocated, so the
transient peak may add nearly one extra decoded cumulative on top of the
settled payload + element-row accounting.
DECLINING MUST ALSO RETIRE THE LOADER FROM THE RUN. runProgressiveRefinement
spins while anyHasMoreLODs() is true, and a declined loader still has more
LODs. If a wrapper only returned false from processLoader the loop would
re-offer it every frame forever, holding the update lock — the same trap
RefinementFailureTracker avoids by excluding exhausted paths from the
aggregation. So callers MUST consult RefinementResidencyBudget.isDeclined
in their
anyHasMoreLODs and getLoaderProgress closures, exactly as they already do
for failures.isExhausted.
Residency budget for sweep-registered progressive LOD leaves.
An ADDITIVE ladder is prefix accumulation: its terminal state is 100% of the node, on every node, always. That bounds FIRST PAINT — the first rung is small — but it bounds nothing at rest, and the refinement loop drives every loader to its last rung with no notion of what the scene can afford.
On the hosted
cosmicflows_laniakeademo that is 10 line nodes x ~1.3M segments = 11.4M segments, and the tab dies withRangeError: Array buffer allocation failedpartway up the ladders (#2426). The eager admission gate innodes/load-children-concurrently.tsalready measures each node against a budget and serialises the initial loads — but it releases in thefinallyaround each child's load, and refinement is scheduled later (lifecycle/load-scene.ts), so by the time the ladders climb, nothing is holding a budget at all. This gate uses the device heap LIMIT already read by the cache sizing path; it does not probe currentusedJSHeapSize.This is a REFUSAL gate, never an evictor. It declines to load MORE; it never discards what is already committed. A node that hits the ceiling simply stops at the rung it reached and stays there — a legible partial scene rather than a dead tab. Legible to a HUMAN, that is: which nodes won the remaining headroom is not deterministic, so the element counts of a stopped scene are not a property of the store and nothing downstream may compare them across builds. That is why RefinementResidencyReporter also RECORDS every refusal for RefinementResidencyStop, which the debug snapshot carries and
core/app/debug/capture-readiness.tsrefuses on (#2508) — a console warning is not something a harness can read. Only loaders registered in the four refinement sweep maps are covered; lazylod_grouplevels are outside those maps and outside this accounting. The budget uses the eager gate's working-set calculation, including itsEAGER_WORKING_SET_CAP_BYTESceiling: sufficiently large desktop heaps all resolve to that same cap rather than scaling without bound. The GPU buffer pool instead takes the full shared non-cache remainder up to its own 2 GB ceiling and covers some of the same renderer element-row bytes. These remain independent ceilings, not additive reservations; eager in-flight bytes, pooled GPU bytes, and refinement residency may coexist transiently.Decoded payload bytes are measured from the typed arrays. Renderer element rows are derived from each geometry's authoritative layout constant, because those allocations are not present in the decoded payload. The renderer's two Uint32 ordering buffers (8 B/element) are intentionally omitted, as is the separately bounded slice cache. The next pass is estimated from the mean accounted rung so far — see estimateNextRungBytes. An admitted loader receives a per-pass share of the remaining headroom across tracked, non-declined paths, with enough allowance for its estimated next rung, and stops after the first level that spends it. The tracked-set denominator deliberately includes completed or currently filtered paths from the raw sweep maps, so it may under-grant relative to the loaders currently offered. This is not scene-wide round-robin fairness: geometry phases run sequentially, and an earlier phase can consume the ceiling before a later phase is offered. The allowance still prevents a warmed cache from consuming the rest of the ladder under one admission. During a fold/commit, the previous cumulative CPU payload can remain reachable while the replacement is allocated, so the transient peak may add nearly one extra decoded cumulative on top of the settled payload + element-row accounting.
DECLINING MUST ALSO RETIRE THE LOADER FROM THE RUN.
runProgressiveRefinementspins whileanyHasMoreLODs()is true, and a declined loader still has more LODs. If a wrapper only returnedfalsefromprocessLoaderthe loop would re-offer it every frame forever, holding the update lock — the same trapRefinementFailureTrackeravoids by excluding exhausted paths from the aggregation. So callers MUST consultRefinementResidencyBudget.isDeclinedin theiranyHasMoreLODsandgetLoaderProgressclosures, exactly as they already do forfailures.isExhausted.