Environment
3d-tiles-renderer 0.5.1, three.js r170, React wrapper (/r3f)
- Google Photorealistic 3D Tiles via
GoogleCloudAuthPlugin (also using
GLTFExtensionsPlugin + Draco, TileCompressionPlugin,
UpdateOnChangePlugin, TilesFadePlugin, LoadRegionPlugin)
- Chrome on macOS (Apple M1 Pro), narrow-FOV ground-level camera
(photography-planning app: the camera FOV follows real lens focal
lengths, so ~7° vertical FOV views are routine)
Summary
When the set of tiles demanded by the current view (everything the
traversal marks used at the configured errorTarget) exceeds
lruCache.maxBytesSize, loading does not degrade — it stops permanently
and silently:
- Admission is hard-gated on
!lruCache.isFull()
(TilesRendererBase.js:857,
:1302–1306),
so no new tile can enter the download queue while the cache is at its
byte cap.
- A tile that finishes downloading and parsing while the cache is
full is discarded outright
(TilesRendererBase.js:1689–1695).
The code comment says "it will be loaded again later from the disk
cache if needed" — but the network transfer may be re-paid (Google
serves private, max-age=14400, so it can come from disk, though
other servers may not allow caching) and the parse cost is always
re-paid. Worse, in the situation below it is never loaded again at
all.
LRUCache.unloadUnusedContent only evicts tiles that are not in the
used set. When the demanded working set itself is what exceeds
maxBytesSize, everything resident is used, nothing can be evicted,
and points 1–2 hold forever: every in-flight parse discards, the
queues drain, tiles-load-end fires, and the renderer sits idle at a
partially refined scene that will never improve. There is no event,
flag, or stat that reports this state — from the outside it is
indistinguishable from a completed load.
A milder version of the same root cause appears while the demanded set is
near (not over) the cap: loading completes but with heavy churn, because
tiles discarded at point 2 are re-requested and re-parsed on later frames.
Observed numbers (Google Photorealistic 3D Tiles)
Churn (demanded set near the cap). errorTarget: 8,
maxBytesSize: 600 MB, rural/suburban area, wide FOV. The load settled
only after 13,848 tile requests for 5,684 loaded models (2.4×), with
cachedBytes pinned within 0.03% of maxBytesSize throughout and
download admission visibly throttled. Raising maxBytesSize to 1 GB made
the identical view load with zero churn (requests == cached items) and a
working set of ~777 MB — i.e. all of the excess traffic was
discard-and-refetch.
Livelock (demanded set over the cap). Same site, errorTarget: 8,
maxBytesSize: 1 GB, camera zoomed to a ~7.5° vertical FOV (SSE demands
~7× finer geometric error, and the narrow frustum reaches deep into the
distance). Downloads (30 parallel) outran parsing (parseQueue.maxJobs
6, main thread), the cache crossed the byte cap mid-drain, and every
parse completing after that point — ~5,500 tiles of bandwidth and decode
— was discarded. Two diagnostics snapshots taken 9 minutes apart then
show:
- loaded-model count byte-identical (5,850 → 5,850),
cachedBytes parked just above maxBytesSize,
- all queues empty,
tiles-load-end long since fired,
- camera translation (which re-runs the traversal) unable to admit a
single tile — queueTileForDownload exits at the isFull() gate.
The scene stays frozen at whatever refinement level the cap caught, with
no error and no signal.
Suggestions
Any of these would help, roughly in order of preference:
- Backpressure instead of discard. Gate download admission on
projected bytes (in-flight + resident) rather than discarding
completed parses. A completed parse is the most expensive artifact in
the pipeline; dropping it to enforce a cap that admission already
overshot pays the cost twice (or, in the livelock, infinitely often —
the same tiles are demanded, admitted when momentarily under cap,
parsed, and discarded again).
- Detect and surface starvation. When
isFull() is true, the used
set alone exceeds maxBytesSize, and the queues are empty, the
renderer is wedged. An event (e.g. cache-starved) and/or a stat
would let applications respond — ours now inflates errorTarget
until demand fits, which un-wedges it within a few seconds.
- Automatic error-target pressure. The renderer itself could treat
"used set > maxBytesSize" as implicit error-target inflation
(analogous to how EnforceNonZeroErrorPlugin handles a different
degenerate case), guaranteeing the cap can never freeze refinement —
a coarser scene instead of a stuck one.
- At minimum, a docs note on
maxBytesSize: it is a hard admission
gate, not a soft target; if a view's demanded working set exceeds it,
loading stalls permanently, and tiles finishing while full are
re-downloaded/re-parsed later.
Workaround we shipped
An application-side watchdog: once per second, if lruCache.isFull() and
the download queue is idle for >2.5 s, multiply errorTarget by 1.5
(compounding, capped ×4) and dispatch needs-update; the coarser demand
marks deep tiles unused, the unloader frees them, and admission resumes.
The multiplier is held (not decayed on a byte threshold — that oscillates
and re-downloads the evicted set each cycle) until the view/preset
changes. This works, but it reconstructs state the renderer already knows
internally — hence suggestion 2.
Happy to provide the full diagnostics snapshots or test against a branch.
Issue details drafted with Claude Code/Fable
Environment
3d-tiles-renderer0.5.1, three.js r170, React wrapper (/r3f)GoogleCloudAuthPlugin(also usingGLTFExtensionsPlugin+ Draco,TileCompressionPlugin,UpdateOnChangePlugin,TilesFadePlugin,LoadRegionPlugin)(photography-planning app: the camera FOV follows real lens focal
lengths, so ~7° vertical FOV views are routine)
Summary
When the set of tiles demanded by the current view (everything the
traversal marks used at the configured
errorTarget) exceedslruCache.maxBytesSize, loading does not degrade — it stops permanentlyand silently:
!lruCache.isFull()(
TilesRendererBase.js:857,:1302–1306),so no new tile can enter the download queue while the cache is at its
byte cap.
full is discarded outright
(
TilesRendererBase.js:1689–1695).The code comment says "it will be loaded again later from the disk
cache if needed" — but the network transfer may be re-paid (Google
serves
private, max-age=14400, so it can come from disk, thoughother servers may not allow caching) and the parse cost is always
re-paid. Worse, in the situation below it is never loaded again at
all.
LRUCache.unloadUnusedContentonly evicts tiles that are not in theused set. When the demanded working set itself is what exceeds
maxBytesSize, everything resident is used, nothing can be evicted,and points 1–2 hold forever: every in-flight parse discards, the
queues drain,
tiles-load-endfires, and the renderer sits idle at apartially refined scene that will never improve. There is no event,
flag, or stat that reports this state — from the outside it is
indistinguishable from a completed load.
A milder version of the same root cause appears while the demanded set is
near (not over) the cap: loading completes but with heavy churn, because
tiles discarded at point 2 are re-requested and re-parsed on later frames.
Observed numbers (Google Photorealistic 3D Tiles)
Churn (demanded set near the cap).
errorTarget: 8,maxBytesSize: 600 MB, rural/suburban area, wide FOV. The load settledonly after 13,848 tile requests for 5,684 loaded models (2.4×), with
cachedBytespinned within 0.03% ofmaxBytesSizethroughout anddownload admission visibly throttled. Raising
maxBytesSizeto 1 GB madethe identical view load with zero churn (requests == cached items) and a
working set of ~777 MB — i.e. all of the excess traffic was
discard-and-refetch.
Livelock (demanded set over the cap). Same site,
errorTarget: 8,maxBytesSize: 1 GB, camera zoomed to a ~7.5° vertical FOV (SSE demands~7× finer geometric error, and the narrow frustum reaches deep into the
distance). Downloads (30 parallel) outran parsing (
parseQueue.maxJobs6, main thread), the cache crossed the byte cap mid-drain, and every
parse completing after that point — ~5,500 tiles of bandwidth and decode
— was discarded. Two diagnostics snapshots taken 9 minutes apart then
show:
cachedBytesparked just abovemaxBytesSize,tiles-load-endlong since fired,single tile —
queueTileForDownloadexits at theisFull()gate.The scene stays frozen at whatever refinement level the cap caught, with
no error and no signal.
Suggestions
Any of these would help, roughly in order of preference:
projected bytes (in-flight + resident) rather than discarding
completed parses. A completed parse is the most expensive artifact in
the pipeline; dropping it to enforce a cap that admission already
overshot pays the cost twice (or, in the livelock, infinitely often —
the same tiles are demanded, admitted when momentarily under cap,
parsed, and discarded again).
isFull()is true, the usedset alone exceeds
maxBytesSize, and the queues are empty, therenderer is wedged. An event (e.g.
cache-starved) and/or a statwould let applications respond — ours now inflates
errorTargetuntil demand fits, which un-wedges it within a few seconds.
"used set > maxBytesSize" as implicit error-target inflation
(analogous to how
EnforceNonZeroErrorPluginhandles a differentdegenerate case), guaranteeing the cap can never freeze refinement —
a coarser scene instead of a stuck one.
maxBytesSize: it is a hard admissiongate, not a soft target; if a view's demanded working set exceeds it,
loading stalls permanently, and tiles finishing while full are
re-downloaded/re-parsed later.
Workaround we shipped
An application-side watchdog: once per second, if
lruCache.isFull()andthe download queue is idle for >2.5 s, multiply
errorTargetby 1.5(compounding, capped ×4) and dispatch
needs-update; the coarser demandmarks deep tiles unused, the unloader frees them, and admission resumes.
The multiplier is held (not decayed on a byte threshold — that oscillates
and re-downloads the evicted set each cycle) until the view/preset
changes. This works, but it reconstructs state the renderer already knows
internally — hence suggestion 2.
Happy to provide the full diagnostics snapshots or test against a branch.
Issue details drafted with Claude Code/Fable