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.
The system runs once per frame. Here's what happens for all 500 buildings:
Step 1 — Get camera position
CameraSystem → activeCamera → CameraComponent.localPosition
Step 2 — Query all LOD entities
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:
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|
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. This prevents flickering when the camera hovers right at a boundary. Switching to coarser LODs has no penalty — it happens immediately.
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.
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.
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.
- 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.
Prior to this integration, LOD and OOC were mutually exclusive: assets that qualified for out-of-core streaming would bypass LOD group detection, causing each LOD0/LOD1/LOD2 object to become an independent stub entity.
How it works now:
setEntityMeshAsync runs LOD group detection before the OOC branching decision. When the asset both qualifies for OOC streaming and contains LOD groups, the LOD+OOC path runs:
-
Registration — one entity per LOD group (not one per MDLObject). Each entity gets a
LODComponentwith stubLODLevels (empty mesh,.notResident) and aStreamingComponent(.unloaded). -
CPU registry —
ProgressiveAssetLoader.cpuLODRegistry[groupEntityId][lodIndex]stores aCPUMeshEntryfor every LOD level. The MDLAsset is retained so CPU buffers remain valid. -
GPU upload — when
GeometryStreamingSystempicks up the entity (.unloaded→ in streaming range), it callsuploadActiveLODFromCPU. This uploads all LOD levels in one pass from the CPU registry, marks eachLODLevel.residencyState = .resident, and setsrenderComponent.meshto the level appropriate for the current camera distance. -
LOD switching after load —
LODSystem.applyLODcontinues to work as normal: it reads fromlodComponent.lodLevels[n].mesh(now populated) and swapsrenderComponent.mesh. No additional streaming requests are needed — all levels are already GPU-resident. -
Cold re-hydration — if
releaseWarmAssetwas called on the root and a group entity re-enters streaming range,rehydrateColdAssetre-parses the USDZ, re-runs LOD detection, and rebuildscpuLODRegistryentries beforeuploadActiveLODFromCPUruns.
Result: the caller sets any MeshStreamingPolicy and LOD assets always get proper LODComponent wiring. The mutual exclusivity that required users to choose between OOC and LOD is eliminated.