Skip to content

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)
The raw 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)

queryEntitiesWithComponentIds([LODComponent, WorldTransformComponent])
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.

distance = |cameraPosition - worldCenter|

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.
  • activeMeshAssetID is 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.
  • EntityLODChangedEvent on SystemEventBus is 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) but enableFadeTransitions defaults to false, 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 .untold mesh assets and swapped by LODSystem.
  • Tile-level LOD is part of setEntityStreamScene(...). The manifest lists .untold LOD payloads, and GeometryStreamingSystem loads/unloads the active tile LOD representation by distance.
  • OCC applies inside large full-tile .untold payloads. It creates child StreamingComponent stubs backed by ProgressiveAssetLoader.CPURuntimeEntry.