UntoldEngine Tile-Based Streaming Architecture
Overview
UntoldEngine implements a multi-tier proximity-based geometry streaming system for large outdoor and indoor scenes on Apple platforms (macOS, visionOS). The system streams geometry in and out of GPU memory based on camera distance, using a spatial octree for efficient runtime range queries. Tile spatial partitioning in the manifest can follow either a uniform grid (v3) or a quadtree floor layout (v4) — see Manifest Versions and Quadtree Partitioning.
Tier 1 — Tile streaming (TileComponent): coarse-grained. Each tile is a whole USDC file covering a bounded region of the world. Tiles load and unload as the camera moves through the scene.
Tier 2 — OCC mesh streaming (StreamingComponent): fine-grained. Inside a loaded tile, individual mesh stubs upload to the GPU incrementally, governed by distance bands and memory budgets.
HLOD (Hierarchical Level of Detail): a coarse proxy mesh shown in place of an unloaded tile. Loaded when the tile leaves range, unloaded when the full tile finishes parsing. Provides continuity for distant geometry without holding the full tile in GPU memory.
Per-tile LOD levels (TileLODLevel): intermediate mesh representations that bridge the gap between the full tile (streamingRadius) and the HLOD switch distance. Finer than HLOD but coarser than the full tile; shown while the tile itself is unloaded and the camera is at mid-range.
Hysteresis: both HLOD and per-tile LOD transitions use a hysteresis band to prevent thrashing when the camera hovers near a switch boundary. A loaded representation is not unloaded until the camera moves meaningfully past the switch threshold (controlled by hlodHysteresisFactor and lodHysteresisFactor, both default 0.90 = 10% inner band). Without hysteresis, frame-to-frame distance jitter near a boundary causes rapid load/unload cycles that freeze the engine.
Distance Band Summary
camera distance → close mid-range far very far
──────────────────────────────────────────────────
full tile ████████████
per-tile LOD 0 ██████
per-tile LOD 1 ██████
HLOD ████████████████████
nothing visible nothing (if no HLOD)
| Band | Representation | Controlled by |
|---|---|---|
< streamingRadius |
Full tile (all submeshes) | TileComponent.state == .parsed |
streamingRadius … LOD[n].switchDistance |
Per-tile LOD n (coarser each step) | TileLODLevel.state |
> last LOD switchDistance … hlodSwitchDistance |
HLOD proxy mesh | TileComponent.hlodState |
> hlodSwitchDistance |
Nothing (tile + HLOD both unloaded) | — |
Data Model
Manifest (JSON)
A scene is described by a manifest file listing tiles.
Top-level manifest fields
| Field | Description |
|---|---|
version |
Integer schema version (3 = uniform grid, 4 = quadtree floor) |
partitioning_mode |
(v4 only) "uniform_grid" or "quadtree_floor" — describes how tiles were partitioned by the export pipeline |
streaming_defaults |
Scene-wide fallback radii and priority used when a tile omits its own values |
tiles |
Array of tile entries (see below) |
shared_bucket |
(optional) A single always-resident tile for geometry that spans many tiles |
tile_size |
(optional) Tile footprint in world units, used to align batch cell size with tile boundaries |
interior_zone |
(v4 only) Union AABB of all ExteriorShell tiles. Interior tiles are only loaded while the camera is inside this volume |
The streaming_defaults block sets scene-wide fallback values for all per-tile fields. An optional shared_bucket entry holds geometry that spans many tiles and should always be resident (loaded as soon as the camera enters the scene).
Per-tile entry fields
| Field | Description |
|---|---|
tile_id |
Human-readable name (e.g. "tile_3_2") |
path_relative_to_manifest |
Path to the USDC file, relative to the manifest |
file_size_bytes |
Pre-computed file size used by the memory budget gate |
bounds.min / bounds.max |
World-space AABB used for octree insertion and frustum tests |
center |
World-space center (used for distance calculations) |
streaming_radius |
(optional) Per-tile load threshold; falls back to streaming_defaults |
unload_radius |
(optional) Per-tile unload threshold; falls back to streaming_defaults |
prefetch_radius |
(optional) Per-tile prefetch start; falls back to streaming_defaults, then auto |
priority |
(optional) Load order when multiple tiles are candidates |
hlod_levels |
(optional) Array of HLOD proxy entries; see HLOD |
lod_levels |
(optional) Array of per-tile intermediate LOD entries; see Per-tile LOD Levels |
floor_id |
(v4 only, optional) Floor index within a building; 0 = ground floor |
quadtree_node_id |
(v4 only, optional) Quadtree node identifier written by the export script (e.g. "F02Q100"); used for debug logging only, not required for streaming |
semantic_tier |
(v4 only, optional) One of "ExteriorShell", "StructuralInterior", "RoomContents", "FineProps". The streaming_radius already encodes the correct load distance for the tier; no additional runtime logic is required |
interior |
(v4 only, optional) When true, this tile contains interior-only geometry and is gated on the camera being inside interior_zone |
HLOD manifest contract
switch_distance is the camera distance beyond which the HLOD is shown in place of the tile. Typically a single entry per tile. The HLOD file is a coarse merged mesh exported by the content pipeline.
Per-tile LOD manifest contract
"lod_levels": [
{ "path": "tiles/tile_0_0_lod1.usdc", "switch_distance": 80.0 },
{ "path": "tiles/tile_0_0_lod2.usdc", "switch_distance": 150.0 }
]
Entries must be sorted ascending by switch_distance (smallest = finest = closest). switch_distance is the camera distance beyond which this LOD is preferred over the finer level (or the full tile). The engine sorts entries on load, so unsorted manifests are corrected at runtime, but sorted manifests are the canonical contract.
ECS Components
TileComponent— attached to every tile stub entity created bysetEntityStreamScene(). Carries all metadata needed for the streaming bootstrap and teardown lifecycle. Key fields added for HLOD and LOD:hlodURL,hlodEntityId,hlodState,hlodSwitchDistance,hlodLoadTask— HLOD lifecyclelodLevels: [TileLODLevel]— per-tile intermediate LOD entriesmeshEntityId— the dedicated mesh-child entity ID, stored so the timeout guard can force-closeAssetLoadingGateifloadTextures()hangsTileLODLevel— one instance per LOD entry in the manifest. Carriesurl,switchDistance,entityId,state(HLODAssetState), andloadTask. Mirrors the HLOD lifecycle pattern.TileLODTagComponent— lightweight tag placed on render-descendant mesh entities spawned by the streaming system for per-tile LOD levels and HLODs. Carries alevelIndexused by the LOD debug renderer (colorRenderablesByLOD) and byBatchingSystem.resolveBatchCandidateto derive the batch LOD index for these entities (which have noLODComponent).levelIndexfollowslodDebugPalette: 1 = LOD1 (green), 2 = LOD2 (blue), 5 = HLOD (cyan).StreamingComponent— attached to individual OCC mesh stubs created inside a loaded tile. Governs per-mesh load/unload within the second streaming tier.RenderComponent— added to an entity only after its GPU geometry upload completes. Absence means the entity is invisible to culling and rendering.
Manifest Versions and Quadtree Partitioning
The manifest schema has evolved across two versions:
v3 — Uniform Grid
"partitioning_mode": "uniform_grid" (or version: 3 without the field). Tiles are laid out in a regular spatial grid. There is no interior_zone and no semantic tier hierarchy. This is the original format produced by the v1 Blender export script.
v4 — Quadtree Floor
"partitioning_mode": "quadtree_floor" (or version: 4). Tiles are partitioned by a floor-level quadtree, typically for multi-storey indoor scenes. The export script assigns each tile a quadtree_node_id (e.g. "F02Q100") and a semantic_tier label.
Semantic tiers encode the expected load distance by naming convention — the export pipeline sets streaming_radius to the correct value for each tier, so the runtime treats them identically during streaming. The tiers are:
| Tier | Description |
|---|---|
ExteriorShell |
Outer building shell, always-visible facade geometry |
StructuralInterior |
Floors, walls, and structural elements inside the shell |
RoomContents |
Furniture and fixtures within individual rooms |
FineProps |
Small detail props, only visible at close range |
Interior zone gating — the manifest's interior_zone is the union AABB of all ExteriorShell tiles. On each streaming tick, the engine checks whether the camera is inside this volume. Tiles with "interior": true are only dispatched for loading while the camera is inside. This prevents the engine from loading room-level geometry when the player is outside the building, regardless of distance.
The quadtree partitioning is a content-pipeline and manifest-level concept. At runtime the engine uses an octree for spatial range queries (finding tile stubs near the camera). The manifest's
quadtree_node_idis used for debug logging only and has no effect on streaming logic.
Tile Lifecycle
States
| State | Meaning |
|---|---|
.unloaded |
Stub registered; no geometry in flight |
.parsing |
setEntityMeshAsync Task is running; GPU upload in progress |
.parsed |
Tile's child entities exist and are rendering (or uploading via OCC) |
.failed |
Last parse attempt failed; exponential backoff before retry (5 s → 10 s → 20 s → max 60 s) |
.unloading |
Teardown in progress; blocks re-dispatch for this tick |
1. Scene Load (setEntityStreamScene)
- Locates and decodes the manifest JSON (no geometry parsed). If the manifest URL is HTTP/HTTPS, it is downloaded and cached via
RemoteAssetDownloaderbefore decoding. Tile asset URLs in the manifest are resolved relative to the manifest's base URL, so remote manifests produce remote tile URLs (e.g.https://cdn.example.com/scene/tiles/tile_0_0.untold). Seeasset_remote_streaming.mdfor the full download lifecycle. - Resets
interiorZoneandfirstRangeTimestampsonGeometryStreamingSystemso stale scene-level state from a previous scene does not bleed into the new one. - Registers the supplied root entity with
TiledSceneComponent,LocalTransformComponent, andScenegraphComponent. - Registers one lightweight stub entity per tile inside a single
withWorldMutationGate, parented under the root entity. Each stub receives: - Identity world transform
LocalTransformComponent.boundingBoxset to the tile's world-space AABBTileComponentin.unloadedstate, with all radii and metadata from the manifest- Octree registration (so
queryNearfinds it immediately)
No geometry is parsed or uploaded at this stage. The whole function completes in milliseconds regardless of scene size.
2. Streaming Update (per tick, ~100 ms steady / ~16 ms burst)
GeometryStreamingSystem.update(cameraPosition:deltaTime:) runs each frame. It queries the octree for all entities within maxQueryRadius (default 500 m). This is a query ceiling, not a load radius — it just defines the outer bound of the candidate pool. Each entity in the result is then tested against its own per-entity radii (effectivePrefetchRadius, streamingRadius, unloadRadius) to decide what actually happens. maxQueryRadius must be large enough to cover the largest unloadRadius in the scene, or far tiles will never be found for out-of-range teardown.
Camera velocity is computed each tick via exponential smoothing and used to project a predictive position (velocityLookAheadTime = 0.5 s ahead). Tile distances are scored against min(actual, predictive) so tiles in the direction of travel are prioritised before the camera physically arrives.
Tile load pass — for each .unloaded stub:
- Computes effective distance using the predictive position.
- Tests against
effectivePrefetchRadius(see Prefetch Radius). - Applies the frustum gate (padded AABB vs camera frustum,
tileFrustumGatePadding = 20 m). Tiles fully outside the frustum are skipped this tick. - Eligible tiles are sorted by priority (descending) then distance (ascending).
- Up to
maxConcurrentTileLoads(default 2) are dispatched vialoadTile(), subject to the memory budget gate: the total parse memory in flight must stay undertileParseMemoryBudgetMB(200 MB), with a guarantee that at least one tile always loads even if it alone exceeds the budget.
Tile unload pass — three sub-passes each tick. All passes use min(actual, predictive) distance, matching the load pass, so a tile the camera is approaching is not torn down mid-parse:
- Nearby tiles still in the octree result but beyond
unloadRadius. - Loaded tiles that drifted entirely outside
maxQueryRadius. - Parsing tiles that drifted outside
maxQueryRadius(fast movement or teleport).
Both .parsing and .parsed tiles go through the grace period (see Unload Grace Period) before actual teardown (passes 1 and 2). Pass 3 tiles are genuinely beyond the 500 m query radius and are cancelled without a grace period — boundary oscillation cannot occur at that range. At most maxTileUnloadsPerUpdate (default 2) tiles are torn down per tick to spread GPU buffer releases across frames.
3. loadTile(entityId:)
- Sets
tileComp.state = .parsing; reserves a slot inactiveTileLoads. - Creates a dedicated child mesh entity under the tile stub (
capturedMeshEntityId) insidewithWorldMutationGate. This guaranteesunloadTile'scollectTileDescendantsalways has at least one child to destroy, regardless of how many submeshes the tile contains. - Registers
capturedMeshEntityId → tileEntityIdinmeshEntityToTileEntityfor O(1) OCC upload counter updates. - Spawns a Swift
TaskcallingsetEntityMeshAsync(entityId: capturedMeshEntityId, streamingPolicy: .auto, blockRenderLoop: false). .autopolicy: the admission gate choosesfullLoad(parse + immediate GPU upload) oroutOfCore(parse to CPU heap, upload stubs viaStreamingComponent) based on tile file size and available RAM.blockRenderLoop: false— tile parses do not hold theAssetLoadingGateopen. Without this, concurrent tile parses would keepisLoadingAny == truefor their full duration, freezingvisibleEntityIdsupdates and stalling the render loop. LOD and HLOD loads also useblockRenderLoop: falsefor the same reason.- Completion callback (fires on the main thread):
- Zombie-state guard — checks
tc.state == .parsing. IfunloadTileran while the parse was in flight, the state will be.unloading. The callback discards the result, destroys the pre-created child entity, and returns without marking the tile loaded. - On confirmed
.parsing: transitions to.parsed, seedstotalOCCStubsfromcountOCCDescendants. - fullLoad path (
occCount == 0): all geometry is immediately GPU-resident. The callback:- Calls
setEntityStaticBatchComponentto tag the entity hierarchy for cell-based static batching. - Calls
BatchingSystem.shared.notifyTileEntitiesResident(_:)with the set of render descendant IDs. This single call replaces the former two-stepqueueResidencyEventsForRenderDescendants+notifyTileParsedEntitiespairing — it directly registers the entities in the batching system's pending additions and marks them for quiescence bypass, avoiding the per-entity event storm throughSystemEventBus. See Tile-Local Batch Promotion.
- Calls
- OCC path (
occCount > 0):setEntityStaticBatchComponentis called but residency notifications are not sent — they fire automatically as each OCC stub completes its GPU upload via the normalhandleResidencyChangeflow. The normal quiescence delay applies to keep the batch from rebuilding after each individual stub upload. - On failure: destroys child entity, increments
failureCount, sets state to.failed(retry backoff). defer { releaseActiveTileLoad }— the concurrency slot is freed on all exit paths.
4. OCC Sub-Mesh Upload (second streaming tier)
For large tiles using the outOfCore path, setEntityMeshAsync creates child OCC stub entities under capturedMeshEntityId, each with a StreamingComponent in .unloaded state. GeometryStreamingSystem.update() iterates these stubs in a separate pass — uploading them in batches governed by maxConcurrentLoads (3), with a nearBandMaxConcurrentLoads = 1 serial slot for the closest mesh stubs so distance-ordered appearance is preserved.
Each completed OCC upload calls incrementParentTileOCCCount(for:), which increments tileComp.uploadedOCCStubs. The visualState property (TileVisualState) tracks upload progress: .empty → .partial → .usable (≥ 50% uploaded) → .complete.
5. unloadTile(entityId:)
- Captures
wasParsing = (tileComp.state == .parsing). - Sets
tileComp.state = .unloading; cancelstileComp.loadTask. - If
wasParsing: removes fromloadingTileEntitiesand bails out. The Task completion callback will find.unloading, discard the result, and dispatch deferred child-entity cleanup — this avoids a concurrent ECS write race sincesetEntityMeshAsyncmay still be running. - If
.parsed: callscollectTileDescendants(entityId)to walk the child tree, cancelling any in-flight OCC streaming tasks. CallsdestroyEntityon all descendants +finalizePendingDestroys(). This releases GPU buffers, removes octree entries, releasesMeshResourceManagerrefs, and unregisters fromMemoryBudgetManager. - Calls
ProgressiveAssetLoader.shared.removeOutOfCoreAsset(rootEntityId:)to free CPU-heap MDLAsset data for out-of-core tiles. - Resets
totalOCCStubs,uploadedOCCStubs,pendingUnloadSinceto 0. - Sets
tileComp.state = .unloaded; removes fromloadedTileEntities.
The tile stub entity itself is never destroyed. It stays in the octree as a cheap placeholder so the streaming system reloads the tile on the next approach.
Prefetch Radius
The prefetch radius decouples "when the tile starts loading" from "when the tile must be visible." Tiles begin parsing as soon as the camera enters effectivePrefetchRadius, which is larger than streamingRadius. By the time the camera reaches streamingRadius, the parse is already complete and the geometry appears without a blank frame.
camera direction →
─────────────────────────────────────────────────────
prefetchRadius (auto: midpoint)
│ streamingRadius
│ │ unloadRadius
▼ ▼ ▼
· · · · · · · ·|· · · · · ·|████████|· · · · · · ·
start tile is stop
loading visible loading
effectivePrefetchRadius resolution (in priority order):
1. Per-tile prefetch_radius field in the manifest
2. Scene-wide prefetch_radius in streaming_defaults
3. Auto: streamingRadius + (unloadRadius − streamingRadius) × 0.5
For the typical streamingRadius = 80 m, unloadRadius = 120 m default, auto resolves to 100 m — giving 20 m of prefetch advance at walking speed (~1.5 m/s) that is ~13 seconds of loading headroom, well above the 1–2 s parse time for a 15–20 MB tile.
Unload Grace Period
When a .parsed tile (with visible GPU geometry) first exceeds unloadRadius, pendingUnloadSince is set to the current time. The tile is only torn down once CFAbsoluteTimeGetCurrent() − pendingUnloadSince ≥ unloadGracePeriod (default 3 seconds).
If the camera re-enters unloadRadius before the grace period expires, pendingUnloadSince is reset to 0 and the tile stays loaded with no interruption. This eliminates rapid load/unload oscillation at tile boundaries (the most common cause of flickering at tile edges).
Both .parsing and .parsed tiles honour the grace period. .parsing tiles have no visible geometry, but the grace window lets an in-flight parse complete naturally rather than being cancelled and immediately re-dispatched. Immediate cancellation was a false economy: the cancelled Swift Task still ran to completion before the state could reset, so the tile was re-dispatched on the very next tick, creating a tight load-cancel loop.
pendingUnloadSince is also reset in unloadTile() so the counter is clean for the next load/unload cycle.
Memory Management
MemoryBudgetManagertracks geometry and texture GPU bytes against per-platform budgets (probed at startup).- Before dispatching any tile load, the system checks
shouldEvictGeometry(). If true, it runsTextureStreamingSystem.shedTextureMemoryandevictLRU(capped at 8 evictions) before attempting a tile parse. This prevents a tile's multi-MB commit from pushing RAM over budget. - LRU eviction scores loaded streaming entities by camera distance ×
evictionDistanceWeight+ GPU size ×evictionSizeWeight. Entities withinvisibleEvictionProtectionRadius(30 m) are protected. - OS memory pressure callbacks (
DispatchSource.makeMemoryPressureSource) set a flag; eviction is deferred to the nextupdate()tick to stay single-threaded. tripleVisibleEntities.clearAll()— called infinalizePendingDestroys()to clear all triple-buffer slots so the renderer does not read stale entity IDs after a scene reload.
Threading Model
- All ECS mutations (
createEntity,registerComponent,destroyEntity,finalizePendingDestroys) must run on the main thread. - Background Swift Tasks handle disk I/O, USDC parsing, and CPU→Metal buffer copies.
withWorldMutationGateis an activity counter, not a mutex. It does not provide mutual exclusion — it signals that ECS mutations are occurring.scene.exists(entityId)guards before every ECS write in upload completions prevent writes to entities destroyed while an upload was in flight (cooperative cancellation race).- Tile tracking sets (
loadedTileEntities,loadingTileEntities,activeTileLoads,meshEntityToTileEntity) are protected bystateLockand accessed only through accessor methods.
Scene Reload Safety
When setEntityStreamScene is called for a second time (replacing a previous scene):
- The caller destroys the old root entity (
destroyEntity(entityId: oldRoot)), which cascades to all tile stubs. For each destroyed stub,removeTileComponent(theComponentRegistrycleanup handler forTileComponent) cancels the tile's in-flightloadTaskand callsGeometryStreamingSystem.shared.unregisterTileEntity(entityId), removing stale IDs from all tracking sets atomically. setEntityStreamSceneresetsinteriorZoneandfirstRangeTimestampsso scene-level streaming state is clean for the new scene.- New stubs are registered under the new root entity.
Any tile Task that was already in flight and completes after step 1–2 finds scene.exists(entityId) == false and returns early via the guard in its completion closure. The defer-based slot release still fires, so no concurrency slot is leaked.
HLOD (Hierarchical Level of Detail)
HLOD fills the gap beyond a tile's unloadRadius by showing a coarse proxy mesh while the full tile is not in GPU memory. This eliminates the visual "pop to nothing" when a tile leaves range.
Lifecycle
| State | Meaning |
|---|---|
.unloaded |
No HLOD geometry in GPU memory |
.loading |
loadHLOD() task running |
.loaded |
HLOD entity exists and is rendering |
.unloading |
Teardown in progress |
Load trigger
HLOD is loaded when all of the following hold:
- The tile has an hlodURL in its TileComponent
- dist >= hlodSwitchDistance (camera is beyond the switch threshold)
- tileComp.state != .parsed (full tile is not resident — no redundant HLOD)
- hlodState == .unloaded
- activeHLODLoadCount() < maxConcurrentHLODLoads (default 4) — prevents simultaneous mass dispatch of 100+ HLOD parses that would OOM-kill the process
Unload trigger
HLOD is unloaded (and the load task cancelled) when:
- The full tile reaches .parsed — full geometry has taken over; HLOD is redundant. No hysteresis here — always unload promptly.
- dist < hlodSwitchDistance × hlodHysteresisFactor (default 0.90) — camera has moved meaningfully inside the switch distance. The hysteresis band [switchDistance × factor, switchDistance) keeps the HLOD resident while the camera lingers at the boundary, preventing thrashing.
Race fix: hlodState = .unloading is set before hlodLoadTask.cancel(). This prevents the load completion callback from seeing .loading and incorrectly marking the HLOD as .loaded after teardown is already in progress.
Minimum dwell guard: After any HLOD load or unload, tileComp.lastHLODTransitionTime is stamped. The next HLOD transition is suppressed until secondaryRepresentationMinDwellSeconds (default 1.0 s) has elapsed. This prevents the HLOD from flip-flopping faster than once per second when the camera lingers at the switch boundary while hysteresis alone is insufficient.
Out-of-range cleanup
A second pass after the main HLOD pass tears down HLOD entities for tiles that have drifted entirely outside maxQueryRadius. These tiles are no longer in the octree result and would otherwise remain with stale .loaded HLOD entities indefinitely.
Per-tile LOD Levels
Per-tile LODs fill the mid-distance band between the full tile (streamingRadius) and the HLOD (hlodSwitchDistance). They are intermediate meshes — coarser than the full tile but finer than HLOD — shown while the tile itself is unloaded.
Distinction from HLOD:
| HLOD | Per-tile LOD | |
|---|---|---|
| Purpose | Replace unloaded tile at very far distances | Provide finer intermediate detail at mid distances |
| Memory concern | Yes — always-resident coarse mesh | Yes — one entity per active LOD level |
| GPU cost concern | Low (coarse mesh) | Medium (finer than HLOD, less than full tile) |
| Distance band | > hlodSwitchDistance |
streamingRadius … hlodSwitchDistance |
Lifecycle
Each TileLODLevel follows the same HLODAssetState state machine as HLOD:
LOD selection logic (per frame, per tile)
if HLOD is loaded or loading → unload all LOD levels (avoid dual representation)
if dist >= hlodSwitchDistance → unload all LOD levels (HLOD pass handles this band)
find target index (with hysteresis):
for each level i:
threshold = (i is currently active) ? switchDistance × lodHysteresisFactor : switchDistance
last level where threshold ≤ dist → that level
no match (dist < LOD[0].threshold) → no LOD (tile will load or is already parsed)
load target level if .unloaded (capped by maxConcurrentLODLoads)
unload all other levels that are .loaded or .loading
The hysteresis ensures the currently active LOD level uses a lowered unload threshold (switchDistance × lodHysteresisFactor, default 0.90), so the camera must move meaningfully inward before the level is swapped. Levels that are not currently active use the full switchDistance.
Minimum dwell guard: LOD transitions are also gated by secondaryRepresentationMinDwellSeconds (default 1.0 s). After any per-tile LOD load or unload, tileComp.lastLODTransitionTime is stamped and the next transition is suppressed until 1.0 s has elapsed. This prevents oscillation between adjacent LOD levels when the camera is stationary near a switch threshold.
When tileComp.state == .parsed (full tile is resident), all LOD levels are unloaded — the full tile has taken over for that distance band.
loadLODLevel(entityId:levelIndex:)
- Creates a child entity under the tile stub.
- Loads the LOD USDC via
setEntityMeshAsyncwith.immediatepolicy andblockRenderLoop: false(small proxy mesh, must not stall the render loop). - On success: tags render descendants with
TileLODTagComponent(levelIndex: capturedIndex + 1)for LOD debug visualization, tags the entity for static batching (setEntityStaticBatchComponent), and callsBatchingSystem.shared.notifyTileEntitiesResident(_:)to bypass the quiescence delay. - Marks the entity in
loadedLODEntitiestracking set.
unloadLODLevel(entityId:levelIndex:)
- Sets
level.state = .unloadingbeforelevel.loadTask?.cancel()(same race fix as HLOD). - Calls
BatchingSystem.shared.cancelPendingEntities(_:)with the render descendant IDs — removes them from all pending batching queues before the entities are destroyed, preventing "entity is missing" errors on the next batching tick. - Destroys the child entity.
- Force-releases
AssetLoadingGatefor the destroyed entity (idempotent no-op if the Task already closed it). - Removes from
loadedLODEntitieswhen all levels are clear.
Out-of-range cleanup
A pass after the LOD streaming pass tears down LOD entities for tiles outside maxQueryRadius, mirroring the HLOD cleanup pass.
Interaction with loadTile completion
When the full tile finishes parsing, unloadAllLODLevels(entityId:) is called alongside unloadHLOD(entityId:). Both intermediate representations are removed as the full tile takes over.
Asset Loading Freeze Prevention
ModelIO's loadTextures() is a blocking call that can hang indefinitely on unsupported image formats inside USDC archives (e.g. a format that stalls the ObjC image decoder with no timeout). When it hangs, AssetLoadingState.finishLoading is never called, AssetLoadingGate.isLoadingAny stays true permanently, and RenderingSystem skips all ECS traversal and culling every frame — the app remains alive but the view is frozen.
Fix: timeout guard + ResumeOnce
loadTextures() in RegistrationSystem is wrapped with a DispatchQueue + 15-second deadline:
let textureLoadOK = await withCheckedContinuation { cont in
let once = ResumeOnce() // NSLock-backed: resumes cont exactly once
DispatchQueue.global(qos: .userInitiated).async {
assetRef.loadTextures()
once.callOnce { cont.resume(returning: true) }
}
DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) {
once.callOnce { cont.resume(returning: false) } // deadline fires
}
}
If loadTextures() hangs, the deadline fires after 15 s and the async continuation proceeds without textures (geometry is still rendered, just untextured).
Force-closing the gate
TileComponent.meshEntityId stores the dedicated mesh-child entity ID. If the tile is unloaded while a parse is in flight and loadTextures() is hung, the timeout guard retrieves meshEntityId and force-closes the gate:
let hungMeshId = tc.meshEntityId
tc.meshEntityId = .invalid
if hungMeshId != .invalid {
Task { await AssetLoadingState.shared.finishLoading(entityId: hungMeshId) }
}
This unblocks AssetLoadingGate, allowing the render loop to resume its normal ECS traversal.
Tile-Parse Watchdog
The loadTextures() 15-second timeout (above) guards against a hung texture decode inside an already-running parse. A separate, coarser watchdog guards against the entire tile parse Task becoming stuck — for example when the OS suspends the Task, disk I/O stalls indefinitely, or a remote download never completes.
On every streaming tick, GeometryStreamingSystem.update() checks every tile currently in .parsing state:
if CFAbsoluteTimeGetCurrent() - tc.parseStartTime > tileParseTimeoutSeconds (default 60 s):
cancel loadTask
force-close AssetLoadingGate for meshEntityId
increment failureCount → tile enters exponential backoff
release concurrency slot
parseStartTime is set after the remote fetch completes, not when loadTile() is first called. For remote tiles, the time waiting for the network does not count against the 60-second budget — only the actual CPU parse time does. This avoids spurious timeouts on slow connections.
Two distinct timeout layers:
| Mechanism | Scope | Deadline | On trigger |
|---|---|---|---|
ResumeOnce + DispatchQueue |
loadTextures() call only |
15 s | Proceeds without textures; geometry still renders |
| Tile-parse watchdog | Entire tile parse Task | 60 s (tileParseTimeoutSeconds) |
Cancels task, marks .failed, enters retry backoff |
Key Design Parameters
| Property | Default | Notes |
|---|---|---|
maxConcurrentTileLoads |
2 | Hard cap on simultaneous tile parses |
maxConcurrentLODLoads |
4 | Hard cap on simultaneous per-tile LOD level loads |
maxConcurrentHLODLoads |
4 | Hard cap on simultaneous HLOD mesh loads |
lodHysteresisFactor |
0.90 | LOD unload threshold multiplier (10% inner band) |
hlodHysteresisFactor |
0.90 | HLOD unload threshold multiplier (10% inner band) |
tileParseMemoryBudgetMB |
200 MB | Total CPU parse memory allowed in flight |
maxTileUnloadsPerUpdate |
2 | Max tile teardowns per streaming tick |
unloadGracePeriod |
3.0 s | Hold time before tearing down a visible tile |
maxConcurrentLoads (OCC) |
3 | Simultaneous mesh-level GPU uploads |
nearBandMaxConcurrentLoads |
1 | Serial slot for closest mesh stubs |
maxUnloadsPerUpdate (mesh) |
12 | Max mesh-level unloads per tick |
updateInterval |
100 ms | Streaming tick rate (steady state) |
burstTickInterval |
16 ms | Tick rate during near-band backlog |
frustumGatePadding (mesh) |
5 m | Frustum pad for mesh-level candidates |
tileFrustumGatePadding |
20 m | Frustum pad for tile-level candidates |
velocityLookAheadTime |
0.5 s | Predictive position look-ahead |
velocityLookAheadMinSpeed |
1.5 m/s | Minimum speed to activate look-ahead |
visibleEvictionProtectionRadius |
30 m | Distance inside which eviction is blocked |
hlodSwitchDistance |
manifest switch_distance |
Camera distance beyond which HLOD is shown |
LOD switchDistance |
manifest switch_distance per entry |
Camera distance beyond which this LOD is preferred |
loadTextures() timeout |
15 s | Deadline before ResumeOnce force-proceeds without textures |
tileParseTimeoutSeconds |
60 s | Watchdog deadline for an entire tile parse Task; forces tile to .failed and frees concurrency slot — distinct from the per-loadTextures() 15 s timeout |
secondaryRepresentationMinDwellSeconds |
1.0 s | Minimum time a HLOD or per-tile LOD level must dwell before the next transition is allowed; prevents flip-flopping between representations faster than once per second |