LOD in UntoldEngine: Two Independent Systems
UntoldEngine has two separate LOD mechanisms that operate at different granularities. Understanding the distinction is important before reading either system's details.
| Entity-level LOD (this document) | Per-tile LOD (tile streaming) | |
|---|---|---|
| Unit | Individual mesh entity (LODComponent) |
Whole tile .untold file (TileLODLevel) |
| Control | LODSystem — called every frame, but the full entity pass is throttled to every lodUpdateFrameInterval frames (default 4) unless the camera moved past minimumCameraDisplacementForLODUpdate (default 0.5 units) |
GeometryStreamingSystem.update() — runs per tick |
| Switch trigger | Camera distance vs LODLevel.maxDistance, or size on screen vs LODLevel.screenPercentage for a component that sets selectsByScreenSize |
Camera distance vs TileLODLevel.switchDistance (with hysteresis) |
| Hysteresis | 5-unit inner band on finer-LOD transitions only, at most a tenth of the level's switch distance | lodHysteresisFactor (default 0.90 = 10% band) on active level |
| Meshes in memory | All LOD levels GPU-resident simultaneously | Only the active LOD level is loaded |
| Use case | Individual detailed objects (buildings, props) | Tile-granularity intermediate representations for large scenes |
| Content pipeline | Separate .untold files per LOD level, wired via LODComponent |
Separate .untold files per tile LOD, listed in manifest lod_levels array |
| Debug tagging | LODComponent.currentLOD read by LOD debug renderer |
TileLODTagComponent.levelIndex placed on render descendants |
Per-tile LOD documentation: docs/Architecture/tilebasedstreaming.md.
Entity-level LOD: City Block with 500 Buildings, 3 LODs Each
Setup (before the system runs)
Each building entity has a LODComponent with an array of LODLevel entries sorted by detail:
Building Entity
LODComponent.lodLevels[0] → LOD0: highDetailMesh, maxDistance: 50.0
LODComponent.lodLevels[1] → LOD1: medDetailMesh, maxDistance: 100.0
LODComponent.lodLevels[2] → LOD2: lowDetailMesh, maxDistance: 200.0
Each LODLevel tracks its own residencyState (.resident, .loading, .notResident, .unknown) and a url so the streaming system knows where to reload it from.
Every Frame: LODSystem.update()
update() is called every frame, but the expensive per-entity pass (Steps 2-3 below) is gated by lodShouldRunThisFrame(...): it always runs on the first call, otherwise it only runs once every LODConfig.shared.lodUpdateFrameInterval frames (default 4), with an early-out fast path that forces an immediate run if the camera has moved more than LODConfig.shared.minimumCameraDisplacementForLODUpdate (default 0.5 units) since the last full pass. So under normal (slow) camera movement, all 500 buildings are re-evaluated roughly every 4th frame rather than every frame; a fast camera jump still triggers an immediate update instead of waiting out the interval.
Step 1 — Get camera position
CameraSystem → activeCamera → CameraComponent.localPosition
SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition)
CameraComponent.localPosition is passed through SceneRootTransform.shared.effectiveCameraPosition(...) before being used for distance checks or the displacement-throttle comparison above — the same scene-root-relative adjustment described in geometryStreamingSystem.md, so LOD distance math stays consistent with tile streaming even when the scene root has been translated (e.g. XR head movement).
Step 2 — Query all LOD entities (only on frames where the throttle above allows a full pass)
Returns all 500 building entities in one shot.Step 3 — For each building: updateEntityLOD()
This is the core loop. Three sub-steps per building:
3a. calculateDistance()
Takes the building's local AABB bounding box, finds its center, transforms it to world space via WorldTransformComponent.space, then computes the straight-line distance to the camera.
3b. selectLODLevel()
Applies lodBias to the distance (default 1.0, so no change), then walks through lodLevels in order, comparing against each level's maxDistance:
adjustedDistance = distance * lodBias
If adjustedDistance ≤ 50 → desiredLOD = 0 (high detail)
If adjustedDistance ≤ 100 → desiredLOD = 1 (medium)
If adjustedDistance ≤ 200 → desiredLOD = 2 (low)
Beyond all thresholds → desiredLOD = 2 (lowest available)
Hysteresis: When switching to a finer LOD (e.g. camera approaching, LOD2→LOD1), the threshold is tightened by 5.0 units, and never by more than a tenth of the threshold itself (lodHysteresisDistanceShare): a level that switches a few units from the camera would otherwise never be returned to. This prevents flickering when the camera hovers right at a boundary. Switching to coarser LODs has no penalty — it happens immediately.
By screen size: a component with selectsByScreenSize set (the levels of a pack's automatic LOD chain are) does not compare against the stored distances. Each level ends at the distance where the entity covers the screenPercentage of the next one:
worldRadius = radius of the sphere around the local AABB (or screenSizeRadius) × largest world scale
reach = projection[1][1] (once per pass, lodScreenSizeReach)
switchDistance = worldRadius × reach / nextLevel.screenPercentage
projection[1][1] is 1 / tan(fovY / 2) of the projection in use (renderInfo.perspectiveSpace), so the switch follows the entity's scale and the field of view. A screen size is a share of the viewport height whatever its resolution, so a denser display draws the same levels. The walk, the bias and the hysteresis are those above, applied to these distances. Under an orthographic projection, and for a level whose successor has no screen size, the stored maxDistance applies. In stereo (renderInfo.isXRStereoMode) the eyes are rendered after the update, so the reach is the larger of the two eyes' reaches from the frame rendered last (xrEye0Projection, xrEye1Projection, lodScreenSizeReach(eyeProjections:)); a headset's field of view does not change between frames, and until an eye has been rendered the projections are the identity and the stored distances apply.
3c. resolveActualLOD()
The desired LOD may not be resident yet (e.g. it's still streaming in). So:
Is desiredLOD mesh resident?
YES → use it (isUsingFallback = false)
NO → findFallbackLOD():
Try coarser LODs first (LOD2, LOD3...)
Then try finer LODs (LOD0...)
If nothing → stay at currentLOD
This means a building 30m away that wants LOD0 but hasn't finished loading will temporarily show LOD1 or LOD2 — always something visible, never a pop-in hole.
3d. applyLOD()
This is the write step. Runs inside withWorldMutationGate to be thread-safe.
If newLOD == currentLOD and no transition in progress → skip (no-op)
Otherwise:
- Update lodComponent.currentLOD = newLOD
- Copy lodLevels[newLOD].mesh → renderComponent.mesh
- Generate meshAssetID: "<url>_LOD<index>"
- Store in lodComponent.activeMeshAssetID (used by batching system)
- If LOD actually changed → emit EntityLODChangedEvent to SystemEventBus
The renderComponent.mesh swap is the handoff to the renderer — whatever mesh array sits there is what gets drawn next frame.
What the 500-Building Frame Looks Like
Given a camera standing near one end of the block:
| Distance | Buildings | Desired LOD | Typical Outcome |
|---|---|---|---|
| 0–50m | ~20 | LOD0 | High detail meshes |
| 50–100m | ~80 | LOD1 | Medium meshes |
| 100–200m | ~150 | LOD2 | Low meshes |
| 200m+ | ~250 | LOD2 (last) | Lowest available |
The system processes all 500 in sequence, but buildings where LOD hasn't changed are early-exited with no mutation (the newLOD == previousLODIndex guard). In a stable scene, the vast majority of buildings hit that fast path.
Key Design Observations
- Fallback-first streaming: The system never waits for a mesh to load — it always degrades gracefully to whatever is resident.
activeMeshAssetIDis the bridge to the batching system. When a LOD switches, the new asset ID tells the batcher to move that entity into a different batch group.EntityLODChangedEventonSystemEventBusis how downstream systems (geometry streaming, batching) learn that a switch happened — they react to the event rather than polling.- Fade transitions exist in the code (
transitionProgress,previousLOD) butenableFadeTransitionsdefaults tofalse, so currently all switches are instant.
Relationship to OCC
The old ModelIO LOD+OOC path has been removed. There is no
cpuLODRegistry, CPUMeshEntry, or cold rehydration path in the current
native streaming architecture.
Current behavior is split by level:
- Entity-level LOD is an always-resident object workflow. LOD levels are loaded
as
.untoldmesh assets and swapped byLODSystem. - Tile-level LOD is part of
setEntityStreamScene(...). The manifest lists.untoldLOD payloads, andGeometryStreamingSystemloads/unloads the active tile LOD representation by distance. - OCC applies inside large full-tile
.untoldpayloads. It creates childStreamingComponentstubs backed byProgressiveAssetLoader.CPURuntimeEntry.