Geometry Streaming System
UntoldEngine streams large worlds through a manifest-driven tiled scene pipeline.
The public rule is simple:
| Use case | API |
|---|---|
| Streamed world geometry (manifest-driven) | setEntityStreamScene(entityId:manifest:withExtension:completion:) |
| Handcrafted streaming zones (no manifest) | StreamingRegionManager — register StreamingRegion AABB + asset lists directly |
| Always-resident assets | setEntityMeshAsync(entityId:filename:withExtension:completion:) |
GeometryStreamingSystem manages the runtime once a streamed scene is loaded. It is not a public component-authoring workflow for standalone entities.
For handcrafted zone streaming without a manifest (e.g. dungeon rooms, level sectors), use
StreamingRegionManager.shared. See the StreamingRegionManager architecture doc for the full API.
Public Workflow
Local manifest
let sceneRoot = createEntity()
setEntityName(entityId: sceneRoot, name: "city")
setEntityStreamScene(entityId: sceneRoot, manifest: "city", withExtension: "json") { success in
setSceneReady(success)
}
Remote manifest
let sceneRoot = createEntity()
setEntityName(entityId: sceneRoot, name: "city")
if let url = URL(string: "https://cdn.example.com/city/city.json") {
setEntityStreamScene(entityId: sceneRoot, url: url) { success in
setSceneReady(success)
}
}
Legacy overloads —
loadTiledScene(manifest:)andloadTiledScene(url:)remain available for backwards compatibility. They create an internal root entity automatically. PrefersetEntityStreamScene(entityId:...)when you need a stable handle to the scene.
Remote manifests are downloaded and cached locally. Tile, HLOD, and per-tile LOD URLs are resolved relative to the manifest URL and fetched on demand.
What Streams
The engine uses multiple geometry layers:
- Full tile: the main tile payload loaded by
loadTile(). Tile assets use the.untoldbinary format, loaded byUntoldReaderwithout ModelIO. - Per-tile LOD: intermediate meshes shown while the full tile is still out of range
- HLOD: coarse far-distance proxy
- OCC sub-mesh stubs: fine-grained
StreamingComponententities created internally inside large tiles
StreamingComponent is internal to the tile-owned OCC path. External callers should not attach it manually or rely on enableStreaming(...).
Manifest Fields That Matter
These are the important fields for geometry streaming:
| Field | Meaning |
|---|---|
streaming_radius |
Full tile display zone |
unload_radius |
Tile teardown threshold |
prefetch_radius |
Background parse threshold before the tile becomes visible |
priority |
Tile load ordering when many tiles compete |
hlod_levels |
Optional far proxy meshes |
lod_levels |
Optional per-tile intermediate LOD meshes |
file_size_bytes |
Parse-budget hint used by the runtime gate |
If prefetch_radius is omitted, the engine computes it automatically from the gap between streaming_radius and unload_radius.
Runtime Behavior
Each update tick, GeometryStreamingSystem:
- Queries the octree within
maxQueryRadius. - Chooses tile parse candidates using predictive camera motion, frustum gating, floor/interior gates, screen-space importance, and loaded-tile occlusion weighting.
- Parses up to
maxConcurrentTileLoadstiles, subject totileParseMemoryBudgetMB. - Streams OCC child meshes inside loaded tiles using
maxConcurrentLoads. - Unloads tiles, LODs, HLODs, and OCC meshes when they leave range or memory pressure requires eviction.
Important defaults:
maxConcurrentTileLoads = 2maxConcurrentLoads = 3maxConcurrentLODLoads = 4maxConcurrentHLODLoads = 4updateInterval = 0.1burstTickInterval = 0.016floorProximityGateY = 5.0minimumParsedTileResidentSeconds = 8.0
Useful Runtime Knobs
// Tile concurrency
GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2
GeometryStreamingSystem.shared.maxConcurrentLoads = 3
GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4
GeometryStreamingSystem.shared.maxConcurrentHLODLoads = 4
// Frustum gate
GeometryStreamingSystem.shared.enableFrustumGate = true
GeometryStreamingSystem.shared.tileFrustumGatePadding = 20.0 // m — wider pad for tiles
GeometryStreamingSystem.shared.frustumGatePadding = 5.0 // m — pad for mesh-level OCC
// Spatial query
GeometryStreamingSystem.shared.maxQueryRadius = 500.0 // must cover farthest unload_radius
// Velocity predictor (predictive tile loading)
GeometryStreamingSystem.shared.velocityLookAheadTime = 0.5 // s — how far ahead to project
GeometryStreamingSystem.shared.velocityLookAheadMinSpeed = 1.5 // m/s — activation threshold
// Interior zone gating (v4 quadtree-floor manifests)
// Tiles tagged interior=true only load when the camera is inside this AABB.
// Set automatically from the manifest; override if needed:
GeometryStreamingSystem.shared.interiorZone = AABB(
min: simd_float3(-10, 0, -10),
max: simd_float3(10, 5, 10)
)
// Floor-aware gating for v4 quadtree-floor manifests.
// Interior tiles with floor metadata only dispatch when their Y center is near the camera.
GeometryStreamingSystem.shared.floorProximityGateY = 5.0
// Tile candidate ordering
GeometryStreamingSystem.shared.enableImportanceSort = true
GeometryStreamingSystem.shared.enableOcclusionSort = true
// Tile unload stability
GeometryStreamingSystem.shared.minimumParsedTileResidentSeconds = 8.0
// Parse safety
GeometryStreamingSystem.shared.tileParseTimeoutSeconds = 60.0 // watchdog deadline per tile
GeometryStreamingSystem.shared.meshLoadTimeoutSeconds = 60.0 // watchdog deadline per OCC mesh load
Use maxQueryRadius large enough to cover the farthest unload_radius in the scene, or out-of-range tiles may not be discovered for teardown.
Session Transitions — forceUnloadAllParsedTiles()
This call immediately unloads every .parsed tile and all resident HLOD and LOD representations, bypassing the 3-second grace period and the 2-per-tick unload cap. It runs synchronously on the main thread: for each tile it destroys the mesh-root child entity and all its descendants via unloadTile(), calls finalizePendingDestroys(), and unregisters the GPU allocation from MemoryBudgetManager. When it returns, shouldEvictGeometry() reflects the freed memory.
Why you need it
Full-load tile GPU memory is tracked by MemoryBudgetManager but is not in loadedStreamingEntities — the set that evictLRU targets. If you start a new tile-loading session while old tiles are still resident, this sequence locks the load loop:
shouldEvictGeometry()istrue— old tiles still occupy the budget.evictLRUruns and frees nothing (wrong entity set).guard !shouldEvictGeometry() else { break }fires every tick.- No new tiles load. The scene freezes until the slow distance-based unload completes (3-second grace period × 2-per-tick cap = 10+ seconds for a large scene).
When to call it
Call forceUnloadAllParsedTiles() whenever you are about to start a new tile-loading session while tiles from a previous one may still be in GPU memory. The two common cases are:
1. Switching between full-scale and calibration/inspection mode
When a user scales the scene down to inspect or reposition it (e.g. a miniature placement workflow), the previous full-scale session's tiles must be freed before they switch back to full scale:
// Entering calibration — free the previous full-scale session's memory.
GeometryStreamingSystem.shared.forceUnloadAllParsedTiles()
scaleSceneTo(calibrationScale)
translateSceneTo(position: placementTarget)
Without this call, every calibration → full-scale cycle leaves more tiles in memory. After a few cycles the memory budget is exhausted, shouldEvictGeometry() permanently breaks the load loop, and the scene freezes.
2. Switching directly from one tiled scene to another
When the user cancels a scene mid-load and immediately loads a different one, tiles from the first scene may be partially or fully parsed:
// User tapped "wrong scene" and is loading a different one.
GeometryStreamingSystem.shared.forceUnloadAllParsedTiles()
destroyEntity(entityId: oldSceneRoot)
setEntityStreamScene(entityId: newSceneRoot, manifest: "correct_scene") { ... }
This guarantees the memory budget is clean before the new scene's first streaming tick runs. Without it there is a race between finalizePendingDestroys() (which frees GPU memory when the old root entity is torn down) and the streaming tick that checks shouldEvictGeometry() — the tick can fire before the destroy finalizes, blocking early tile loads in the new scene.
When NOT to call it
- Normal camera movement and room transitions — the distance-based unload pass handles these with appropriate hysteresis. Calling
forceUnloadAllParsedTiles()here would discard tiles the user is about to need. - Returning to a menu with no subsequent tile loading —
destroyEntityon the scene root cascades through all tile stubs; their component cleanup (removeTileComponent) cancels in-flight tasks and removes them from tracking sets. No explicit unload is needed.
The rule of thumb: call it whenever you know a new tile-streaming session is about to start and you cannot guarantee the previous session has already finished unloading.
Interaction with Other Systems
- Texture streaming:
setEntityStreamScene(...)automatically aligns texture distance bands to the manifest radii. - Batching: full-load tiles, per-tile LODs, and HLODs notify
BatchingSystemautomatically. OCC sub-mesh uploads join batching incrementally through normal residency events. - Memory pressure: texture quality is shed first; geometry eviction follows only when geometry pressure remains high.
- Tile geometry eviction: if OCC eviction cannot clear geometry pressure, the system can evict full-load tiles, HLODs, and per-tile LODs through
evictTileGeometry, while protecting tiles inside their ownstreamingRadiusand respecting the parsed-tile minimum dwell.
Common Problems
Tiles pop in on camera rotation
- Increase
GeometryStreamingSystem.shared.tileFrustumGatePadding - Keep
enableFrustumGate = true
Tiles unload and reload too aggressively
- Increase the gap between
streaming_radiusandunload_radius - Increase or explicitly author
prefetch_radius
Tile parse bursts spike memory
- Lower
maxConcurrentTileLoads - Reduce per-tile file sizes in the exported manifest
Streaming does nothing
- Verify you loaded the scene through
setEntityStreamScene(...) - Verify the manifest radii are reasonable for your scene scale
- Do not expect standalone
StreamingComponententities to stream; tile ownership is enforced