From 7a65e46989d0fc7813ae0a3b07fd0650ea7c562c Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 26 May 2026 23:15:08 -0700 Subject: [PATCH 1/2] [Patch] Fix tile streaming fallback coverage for LOD/HLOD scenes --- ...eometryStreamingSystem+TileStreaming.swift | 10 +- .../Systems/GeometryStreamingSystem.swift | 305 +++++++++++++----- .../TileStreamingTests.swift | 223 ++++++++++++- 3 files changed, 437 insertions(+), 101 deletions(-) diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift index 5c92be07..91450a1c 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift @@ -547,10 +547,12 @@ extension GeometryStreamingSystem { self.markLoadedTileEntity(entityId) self.recordTileRepresentationSwap(entityId: entityId, tileId: tileId, representation: "tile:parsed") - // Full geometry is now resident — unload the coarse HLOD mesh - // and any per-tile LOD levels that were showing while loading. - self.unloadHLOD(entityId: entityId) - self.unloadAllLODLevels(entityId: entityId) + // Only drop fallback coverage once full geometry is renderable. + // OCC tiles may be parsed before enough child stubs have uploaded. + if self.tileHasUsableFullGeometry(tc) { + self.unloadHLOD(entityId: entityId) + self.unloadAllLODLevels(entityId: entityId) + } // Tag the tile's mesh hierarchy for cell-based static batching. // setEntityStaticBatchComponent walks the full child tree and diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index 73c16419..68f88034 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -295,6 +295,16 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Screen-space rectangle in NDC [-1, 1] × [-1, 1]. // Used to represent the projected AABB footprint of a tile for occlusion scoring. struct TileOccluder { let rect: ScreenRect; let distance: Float } + struct TileRepresentationCandidate { + let entityId: EntityID + let distance: Float + let priority: Int + let solidAngle: Float + let viewAlignment: Float + let occlusionScore: Float + let levelIndex: Int + } + struct ScreenRect { var minX: Float var minY: Float @@ -911,15 +921,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { // local AABB equals their world AABB. Uses tileStreamingFrustum which // applies tileFrustumGatePadding (wider than the mesh-level pad) to // prevent tile pop-in during fast rotation on coarse tile boundaries. - if let f = tileStreamingFrustum, - let local = scene.get(component: LocalTransformComponent.self, for: entityId) - { - let center = (local.boundingBox.min + local.boundingBox.max) * 0.5 - let halfExtent = (local.boundingBox.max - local.boundingBox.min) * 0.5 - if !isAABBInFrustum(center: center, halfExtent: halfExtent, frustum: f) { - continue - } - } + if !tilePassesStreamingFrustum(entityId: entityId, frustum: tileStreamingFrustum) { continue } let (sa, va) = tileImportanceComponents( entityId: entityId, distance: effectiveDist, @@ -998,54 +1000,11 @@ public class GeometryStreamingSystem: @unchecked Sendable { } } - // ── HLOD streaming pass ──────────────────────────────────────────────── - // For tiles that have an HLOD mesh configured: load the coarse mesh when the - // camera is beyond hlodSwitchDistance and the tile is not yet loading/loaded. - // Unload the HLOD when the full tile becomes .parsed (smooth hand-off). - // During .parsing the HLOD stays visible so there is no blank frame while the - // full geometry is uploading. - for entityId in nearbyEntities { - guard scene.exists(entityId) else { continue } - guard let tileComp = scene.get(component: TileComponent.self, for: entityId), - tileComp.hlodURL != nil, - tileComp.hlodSwitchDistance > 0 else { continue } - - let dist = calculateDistance(entityId: entityId, cameraPosition: effectiveCameraPosition) - let canTransitionHLOD = tileComp.lastHLODTransitionTime == 0 || - timeoutNow - tileComp.lastHLODTransitionTime >= secondaryRepresentationMinDwellSeconds - - switch tileComp.state { - case .unloaded, .failed: - if dist > tileComp.hlodSwitchDistance { - if tileComp.hlodState == .unloaded { - guard activeHLODLoadCount() < maxConcurrentHLODLoads else { continue } - loadHLOD(entityId: entityId) - } - } else if dist < tileComp.hlodSwitchDistance * hlodHysteresisFactor { - // Camera has moved meaningfully inside the switch distance — - // HLOD is no longer needed. The hysteresis band - // [switchDistance * factor, switchDistance) keeps the HLOD - // resident while the camera lingers at the boundary. - if canTransitionHLOD, tileComp.hlodState != .unloaded { unloadHLOD(entityId: entityId) } - } - // else: inside hysteresis band — keep current HLOD state. - case .parsed: - // Full geometry is resident — HLOD hand-off complete. - // No dwell here: unload promptly so HLOD and full tile are not - // both resident simultaneously consuming GPU memory. - if tileComp.hlodState != .unloaded { unloadHLOD(entityId: entityId) } - case .parsing, .unloading: - // Keep HLOD visible during full-tile load for a seamless transition. - break - } - } - // ── Per-tile LOD streaming pass ──────────────────────────────────────── - // For tiles that have LOD levels: show the appropriate coarser mesh when - // the tile is unloaded and the camera is between the LOD switch distances. - // Levels are sorted ascending by switchDistance (finest first), so the - // active index is the last one whose switchDistance ≤ current distance. - // Only one level is active at a time; all others are unloaded. + // LOD load work is admitted before HLOD work because LOD covers mid/near-field + // tiles where empty red bounds are most visible. Candidate sorting mirrors the + // full tile path so nearby, screen-dominant tiles win load slots first. + var lodLoadCandidates: [TileRepresentationCandidate] = [] for entityId in nearbyEntities { guard scene.exists(entityId) else { continue } guard let tileComp = scene.get(component: TileComponent.self, for: entityId), @@ -1053,16 +1012,18 @@ public class GeometryStreamingSystem: @unchecked Sendable { let dist = calculateDistance(entityId: entityId, cameraPosition: effectiveCameraPosition) - // LOD levels are only active while HLOD is not resident. - // If HLOD is loaded or loading (including the hysteresis band where dist is - // slightly below hlodSwitchDistance), keep LOD levels clear to avoid showing - // both representations simultaneously. Only when HLOD is unloaded (or - // unloading) do we let the LOD pass activate. + // LOD levels are only cleared for HLOD when the HLOD is actually renderable. + // If an HLOD is merely loading, keep the previous LOD as coverage until the + // replacement is resident. if tileComp.hlodSwitchDistance > 0 { - let hlodResident = tileComp.hlodState == .loaded || tileComp.hlodState == .loading + let hlodLoaded = tileComp.hlodState == .loaded + let hlodLoading = tileComp.hlodState == .loading let beyondHLOD = dist >= tileComp.hlodSwitchDistance - if beyondHLOD || hlodResident { - unloadAllLODLevels(entityId: entityId) + let inHLODHysteresisBand = dist >= tileComp.hlodSwitchDistance * hlodHysteresisFactor + if beyondHLOD || hlodLoading || (hlodLoaded && inHLODHysteresisBand) { + if hlodLoaded { + unloadAllLODLevels(entityId: entityId) + } continue } } @@ -1087,39 +1048,131 @@ public class GeometryStreamingSystem: @unchecked Sendable { let canTransitionLOD = tileComp.lastLODTransitionTime == 0 || timeoutNow - tileComp.lastLODTransitionTime >= secondaryRepresentationMinDwellSeconds + let fallbackTargetIndex = targetIndex ?? (!tileHasUsableFullGeometry(tileComp) ? tileComp.lodLevels.indices.first : nil) + switch tileComp.state { - case .unloaded, .failed: - if let target = targetIndex { - for i in tileComp.lodLevels.indices { - if i == target { - if tileComp.lodLevels[i].state == .unloaded, currentlyActiveIndex == nil || canTransitionLOD { - // Hard cap: never exceed maxConcurrentLODLoads. - // Without this, every tile in LOD range is dispatched - // simultaneously on initial load, triggering an OOM kill. - guard activeLODLoadCount() < maxConcurrentLODLoads else { break } - loadLODLevel(entityId: entityId, levelIndex: i) - } - } else { - if canTransitionLOD, tileComp.lodLevels[i].state != .unloaded { - unloadLODLevel(entityId: entityId, levelIndex: i) - } - } - } + case .unloaded, .failed, .parsing: + if let target = fallbackTargetIndex { + lodLoadCandidates.append(makeTileRepresentationCandidate( + entityId: entityId, + distance: dist, + priority: tileComp.priority, + levelIndex: target, + cameraPosition: effectiveCameraPosition, + tileOccluders: tileOccluders + )) } else if canTransitionLOD { - // Camera is inside the finest LOD's switch distance — full tile - // will load via the tile load pass; drop any active LOD level. unloadAllLODLevels(entityId: entityId) } case .parsed: - // Full geometry resident — all LOD levels can be dropped. - // No dwell: unload immediately so LOD proxies don't stay resident - // alongside the full tile geometry. - unloadAllLODLevels(entityId: entityId) - case .parsing, .unloading: + if tileHasUsableFullGeometry(tileComp) { + unloadAllLODLevels(entityId: entityId) + } else if let target = fallbackTargetIndex { + lodLoadCandidates.append(makeTileRepresentationCandidate( + entityId: entityId, + distance: dist, + priority: tileComp.priority, + levelIndex: target, + cameraPosition: effectiveCameraPosition, + tileOccluders: tileOccluders + )) + } + case .unloading: // Keep active LOD visible during the full-tile load for continuity. break } } + sortTileRepresentationCandidates(&lodLoadCandidates) + for candidate in lodLoadCandidates { + guard scene.exists(candidate.entityId), + let tileComp = scene.get(component: TileComponent.self, for: candidate.entityId), + candidate.levelIndex < tileComp.lodLevels.count + else { continue } + + let currentlyActiveIndex = tileComp.lodLevels.indices.first(where: { + let s = tileComp.lodLevels[$0].state + return s == .loaded || s == .loading + }) + let canTransitionLOD = tileComp.lastLODTransitionTime == 0 || + timeoutNow - tileComp.lastLODTransitionTime >= secondaryRepresentationMinDwellSeconds + + if tileComp.lodLevels[candidate.levelIndex].state == .unloaded, + currentlyActiveIndex == nil || canTransitionLOD + { + guard activeLODLoadCount() < maxConcurrentLODLoads else { break } + loadLODLevel(entityId: candidate.entityId, levelIndex: candidate.levelIndex) + } + + for i in tileComp.lodLevels.indices where i != candidate.levelIndex { + if canTransitionLOD, + tileComp.lodLevels[candidate.levelIndex].state == .loaded, + tileComp.lodLevels[i].state != .unloaded + { + unloadLODLevel(entityId: candidate.entityId, levelIndex: i) + } + } + } + + // ── HLOD streaming pass ──────────────────────────────────────────────── + // HLODs are sorted too, but are admitted after full-tile and LOD candidates. + // This gives nearby/mid-field coverage first chance at load slots while still + // allowing far-field proxies to fill their own cap during cold scene startup. + var hlodLoadCandidates: [TileRepresentationCandidate] = [] + for entityId in nearbyEntities { + guard scene.exists(entityId) else { continue } + guard let tileComp = scene.get(component: TileComponent.self, for: entityId), + tileComp.hlodURL != nil, + tileComp.hlodSwitchDistance > 0 else { continue } + + let dist = calculateDistance(entityId: entityId, cameraPosition: effectiveCameraPosition) + let canTransitionHLOD = tileComp.lastHLODTransitionTime == 0 || + timeoutNow - tileComp.lastHLODTransitionTime >= secondaryRepresentationMinDwellSeconds + + switch tileComp.state { + case .unloaded, .failed: + if dist > tileComp.hlodSwitchDistance { + if tileComp.hlodState == .unloaded { + hlodLoadCandidates.append(makeTileRepresentationCandidate( + entityId: entityId, + distance: dist, + priority: tileComp.priority, + levelIndex: -1, + cameraPosition: effectiveCameraPosition, + tileOccluders: tileOccluders + )) + } + } else if dist < tileComp.hlodSwitchDistance * hlodHysteresisFactor { + // Camera has moved meaningfully inside the switch distance — + // HLOD is no longer needed. The hysteresis band + // [switchDistance * factor, switchDistance) keeps the HLOD + // resident while the camera lingers at the boundary. + if canTransitionHLOD, + tileComp.hlodState != .unloaded, + tileHasLoadedLOD(tileComp) || tileHasUsableFullGeometry(tileComp) + { + unloadHLOD(entityId: entityId) + } + } + // else: inside hysteresis band — keep current HLOD state. + case .parsed: + // Full geometry must be renderable before HLOD coverage is dropped. + if tileHasUsableFullGeometry(tileComp), tileComp.hlodState != .unloaded { + unloadHLOD(entityId: entityId) + } + case .parsing, .unloading: + // Keep HLOD visible during full-tile load for a seamless transition. + break + } + } + sortTileRepresentationCandidates(&hlodLoadCandidates) + for candidate in hlodLoadCandidates { + guard activeHLODLoadCount() < maxConcurrentHLODLoads else { break } + guard scene.exists(candidate.entityId), + let tileComp = scene.get(component: TileComponent.self, for: candidate.entityId), + tileComp.hlodState == .unloaded + else { continue } + loadHLOD(entityId: candidate.entityId) + } // Also check loaded entities that might now be out of range // (they may not be in the octree query if they're far away) @@ -1707,6 +1760,82 @@ public class GeometryStreamingSystem: @unchecked Sendable { return max(occlusionMinWeight, 1.0 - Float(coveredCells) / Float(candidateCells)) } + func tilePassesStreamingFrustum(entityId: EntityID, frustum: Frustum?) -> Bool { + guard CameraSystem.shared.activeCamera != nil else { return true } + guard let f = frustum, + let local = scene.get(component: LocalTransformComponent.self, for: entityId) + else { return true } + let center = (local.boundingBox.min + local.boundingBox.max) * 0.5 + let halfExtent = (local.boundingBox.max - local.boundingBox.min) * 0.5 + return isAABBInFrustum(center: center, halfExtent: halfExtent, frustum: f) + } + + func makeTileRepresentationCandidate( + entityId: EntityID, + distance: Float, + priority: Int, + levelIndex: Int, + cameraPosition: simd_float3, + tileOccluders: [TileOccluder] + ) -> TileRepresentationCandidate { + let (solidAngle, viewAlignment) = tileImportanceComponents( + entityId: entityId, + distance: distance, + cameraPosition: cameraPosition, + cameraForward: lastCameraForward + ) + + let occlusionScore: Float + if enableOcclusionSort, !tileOccluders.isEmpty, + let local = scene.get(component: LocalTransformComponent.self, for: entityId) + { + let rect = projectAABBToScreen( + min: local.boundingBox.min, max: local.boundingBox.max, + viewProj: lastViewProjMatrix + ) + occlusionScore = tileOcclusionScore( + candidateRect: rect, + distance: distance, + occluders: tileOccluders + ) + } else { + occlusionScore = 1.0 + } + + return TileRepresentationCandidate( + entityId: entityId, + distance: distance, + priority: priority, + solidAngle: solidAngle, + viewAlignment: viewAlignment, + occlusionScore: occlusionScore, + levelIndex: levelIndex + ) + } + + func sortTileRepresentationCandidates(_ candidates: inout [TileRepresentationCandidate]) { + let maxSA = candidates.max(by: { $0.solidAngle < $1.solidAngle })?.solidAngle ?? 1.0 + let saFloor = max(maxSA, 1e-6) + candidates.sort { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if enableImportanceSort { + let lScore = (lhs.solidAngle / saFloor) * lhs.viewAlignment * lhs.occlusionScore + let rScore = (rhs.solidAngle / saFloor) * rhs.viewAlignment * rhs.occlusionScore + if abs(lScore - rScore) > 0.001 { return lScore > rScore } + } + return lhs.distance < rhs.distance + } + } + + func tileHasUsableFullGeometry(_ tileComp: TileComponent) -> Bool { + guard tileComp.state == .parsed else { return false } + return tileComp.visualState == .usable || tileComp.visualState == .complete + } + + func tileHasLoadedLOD(_ tileComp: TileComponent) -> Bool { + tileComp.lodLevels.contains { $0.state == .loaded } + } + func calculateDistance(entityId: EntityID, cameraPosition: simd_float3) -> Float { guard let transform = scene.get(component: WorldTransformComponent.self, for: entityId), let local = scene.get(component: LocalTransformComponent.self, for: entityId) diff --git a/Tests/UntoldEngineRenderTests/TileStreamingTests.swift b/Tests/UntoldEngineRenderTests/TileStreamingTests.swift index 930a7238..45b2eecf 100644 --- a/Tests/UntoldEngineRenderTests/TileStreamingTests.swift +++ b/Tests/UntoldEngineRenderTests/TileStreamingTests.swift @@ -672,6 +672,7 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { GeometryStreamingSystem.shared.updateInterval = 0.0 // no throttle GeometryStreamingSystem.shared.lodHysteresisFactor = hysteresisFactor GeometryStreamingSystem.shared.maxConcurrentLODLoads = 4 + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 0 GeometryStreamingSystem.shared.maxQueryRadius = 500.0 OctreeSystem.shared.clear() @@ -685,6 +686,7 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { GeometryStreamingSystem.shared.reset() GeometryStreamingSystem.shared.enabled = false GeometryStreamingSystem.shared.updateInterval = 0.1 + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 2 OctreeSystem.shared.clear() MemoryBudgetManager.shared.clear() try await super.tearDown() @@ -702,7 +704,9 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { private func makeTileEntity( distance: Float, lodSwitchDistance: Float, - initialLODState: HLODAssetState = .unloaded + initialLODState: HLODAssetState = .unloaded, + hlodSwitchDistance: Float = 0, + initialHLODState: HLODAssetState = .unloaded ) -> (EntityID, TileComponent) { let entityId = createEntity() @@ -734,9 +738,11 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { tileComp.tileId = "test_tile_\(entityId)" tileComp.streamingRadius = 50.0 tileComp.unloadRadius = 200.0 - // No HLOD — ensures the LOD pass is not gated by hlodSwitchDistance. - tileComp.hlodSwitchDistance = 0 - tileComp.hlodState = .unloaded + tileComp.hlodSwitchDistance = hlodSwitchDistance + tileComp.hlodState = initialHLODState + if hlodSwitchDistance > 0 { + tileComp.hlodURL = URL(fileURLWithPath: "/dev/null") + } // One LOD level at the requested switchDistance. // entityId = .invalid so unloadLODLevel skips the GPU destroy path. @@ -801,10 +807,12 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { ) } - /// Camera below the hysteresis band: - /// the active LOD level must be torn down. - func testLODHysteresis_activeLevel_unloadedBelowBand() { - // Near face at 89 → dist=89 < threshold=90 → targetIndex nil → unloadAllLODLevels. + /// Camera below the hysteresis band but full geometry is not ready: + /// the active LOD level remains as visual coverage. + func testLODHysteresis_activeLevel_preservedBelowBandUntilFullGeometryUsable() { + // Near face at 89 → dist=89 < threshold=90 → targetIndex nil. The old + // behavior unloaded the LOD immediately; the handoff rule keeps it until + // full tile geometry is parsed and visually usable. let distBelowBand: Float = hysteresisThreshold - 1 // 89 let (_, tileComp) = makeTileEntity( @@ -819,9 +827,206 @@ final class TileStreamingHysteresisTests: BaseRenderSetup { ) XCTAssertEqual( + tileComp.lodLevels[0].state, .loaded, + "LOD should remain loaded until full geometry is usable (dist=\(distBelowBand), threshold=\(hysteresisThreshold))" + ) + } + + /// Once the full tile is parsed and visually usable, the fallback LOD can be dropped. + func testLODHysteresis_activeLevel_unloadedBelowBandWhenFullGeometryUsable() { + let distBelowBand: Float = hysteresisThreshold - 1 + + let (_, tileComp) = makeTileEntity( + distance: distBelowBand, + lodSwitchDistance: lodSwitchDistance, + initialLODState: .loaded + ) + tileComp.state = .parsed + tileComp.totalOCCStubs = 0 + tileComp.uploadedOCCStubs = 0 + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertEqual( + tileComp.lodLevels[0].state, .unloaded, + "LOD should unload after full geometry becomes visually usable" + ) + } + + /// A cold-start camera inside the full-detail zone should still get temporary + /// LOD coverage while the full tile is waiting to parse. + func testFullZoneFallback_inactiveFinestLODLoadsUntilFullGeometryUsable() { + let distanceInsideFullZone: Float = 10 + + let (_, tileComp) = makeTileEntity( + distance: distanceInsideFullZone, + lodSwitchDistance: lodSwitchDistance, + initialLODState: .unloaded + ) + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertNotEqual( + tileComp.lodLevels[0].state, .unloaded, + "Finest LOD should be requested as fallback coverage inside the full-detail zone" + ) + } + + func testFullZoneFallback_parsingTileRequestsFinestLOD() { + let distanceInsideFullZone: Float = 10 + + let (_, tileComp) = makeTileEntity( + distance: distanceInsideFullZone, + lodSwitchDistance: lodSwitchDistance, + initialLODState: .unloaded + ) + tileComp.state = .parsing + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertNotEqual( + tileComp.lodLevels[0].state, .unloaded, + "Finest LOD should cover the tile while full geometry is parsing" + ) + } + + func testFullZoneFallback_partialParsedTileRequestsFinestLOD() { + let distanceInsideFullZone: Float = 10 + + let (_, tileComp) = makeTileEntity( + distance: distanceInsideFullZone, + lodSwitchDistance: lodSwitchDistance, + initialLODState: .unloaded + ) + tileComp.state = .parsed + tileComp.totalOCCStubs = 10 + tileComp.uploadedOCCStubs = 4 + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertNotEqual( tileComp.lodLevels[0].state, .unloaded, - "LOD should be unloaded when camera drops below hysteresis band (dist=\(distBelowBand), threshold=\(hysteresisThreshold))" + "Finest LOD should remain available until parsed full geometry is visually usable" + ) + } + + /// Moving inward from HLOD range should first start the LOD replacement and keep + /// the HLOD visible. The HLOD is dropped only after a LOD is actually loaded. + func testHLODHandoff_preservesHLODUntilLODLoaded() { + let distanceInsideHLODBand: Float = 85 + + let (_, tileComp) = makeTileEntity( + distance: distanceInsideHLODBand, + lodSwitchDistance: 50, + initialLODState: .unloaded, + hlodSwitchDistance: 100, + initialHLODState: .loaded + ) + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 ) + + XCTAssertEqual(tileComp.hlodState, .loaded, "HLOD should remain visible while replacement LOD is loading") + XCTAssertNotEqual(tileComp.lodLevels[0].state, .unloaded, "LOD replacement should be requested") + } + + func testHLODHandoff_unloadsHLODAfterLODLoaded() { + let distanceInsideHLODBand: Float = 85 + + let (_, tileComp) = makeTileEntity( + distance: distanceInsideHLODBand, + lodSwitchDistance: 50, + initialLODState: .loaded, + hlodSwitchDistance: 100, + initialHLODState: .loaded + ) + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertEqual(tileComp.hlodState, .unloaded, "HLOD can unload once LOD coverage is loaded") + XCTAssertEqual(tileComp.lodLevels[0].state, .loaded, "Loaded LOD should remain as coverage") + } + + func testLODSwap_preservesOldLODWhileTargetLODLoads() { + let (_, tileComp) = makeTileEntity( + distance: 75, + lodSwitchDistance: 50, + initialLODState: .unloaded + ) + let coarser = TileLODLevel(url: URL(fileURLWithPath: "/dev/null"), switchDistance: 100) + coarser.state = .loaded + coarser.entityId = .invalid + tileComp.lodLevels.append(coarser) + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertNotEqual(tileComp.lodLevels[0].state, .unloaded, "Target finer LOD should be requested") + XCTAssertEqual(tileComp.lodLevels[1].state, .loaded, "Old coarser LOD should remain visible while target loads") + } + + func testLODSwap_unloadsOldLODAfterTargetLODLoaded() { + let (_, tileComp) = makeTileEntity( + distance: 75, + lodSwitchDistance: 50, + initialLODState: .loaded + ) + let coarser = TileLODLevel(url: URL(fileURLWithPath: "/dev/null"), switchDistance: 100) + coarser.state = .loaded + coarser.entityId = .invalid + tileComp.lodLevels.append(coarser) + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertEqual(tileComp.lodLevels[0].state, .loaded, "Target finer LOD should remain loaded") + XCTAssertEqual(tileComp.lodLevels[1].state, .unloaded, "Old coarser LOD can unload after target is loaded") + } + + func testHLODAdmission_allowsFarCoverageAfterLODAdmission() { + let (_, lodTileComp) = makeTileEntity( + distance: 75, + lodSwitchDistance: 50, + initialLODState: .unloaded + ) + + let (_, hlodTileComp) = makeTileEntity( + distance: 150, + lodSwitchDistance: 50, + initialLODState: .unloaded, + hlodSwitchDistance: 100, + initialHLODState: .unloaded + ) + hlodTileComp.lodLevels = [] + + GeometryStreamingSystem.shared.update( + cameraPosition: simd_float3(0, 0, 0), + deltaTime: 0.016 + ) + + XCTAssertNotEqual(lodTileComp.lodLevels[0].state, .unloaded, "Nearby LOD coverage should be admitted first") + XCTAssertNotEqual(hlodTileComp.hlodState, .unloaded, "Far HLOD coverage should still be admitted when HLOD capacity is free") } /// Inactive LOD level (state = .unloaded) at dist inside the hysteresis band: From 0311a0800cb65a56a2d1e10248aeca32ca4a8da0 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 26 May 2026 23:34:37 -0700 Subject: [PATCH 2/2] [Patch] Added dedicated log categories --- Sources/UntoldEngine/Mesh/Mesh.swift | 35 +++++++++--- ...eometryStreamingSystem+TileStreaming.swift | 40 ++++++++++--- .../Systems/GeometryStreamingSystem.swift | 16 ++++-- .../Systems/TextureStreamingSystem.swift | 20 +++++-- Sources/UntoldEngine/Utils/Logger.swift | 8 +++ .../LoggerCategoryTests.swift | 39 +++++++++++++ docs/API/UsingTheLogger.md | 56 ++++++++++++++----- 7 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 Tests/UntoldEngineTests/LoggerCategoryTests.swift diff --git a/Sources/UntoldEngine/Mesh/Mesh.swift b/Sources/UntoldEngine/Mesh/Mesh.swift index 5e0edc4a..c25b425e 100644 --- a/Sources/UntoldEngine/Mesh/Mesh.swift +++ b/Sources/UntoldEngine/Mesh/Mesh.swift @@ -666,7 +666,10 @@ public struct Material { func loadRuntimeTexture(_ label: String, reference: RuntimeTextureReference?, isSRGB: Bool) -> MTLTexture? { guard let reference, let url = reference.sourceURL else { return nil } let fileExists = fileManager.fileExists(atPath: url.path) - Logger.log(message: "[UntoldTexture] \(label) '\(runtimeMaterial.name ?? "")' -> \(url.path) | exists=\(fileExists)") + Logger.log( + message: "[UntoldTexture] \(label) '\(runtimeMaterial.name ?? "")' -> \(url.path) | exists=\(fileExists)", + category: LogCategory.textureLoading.rawValue + ) // ASTC textures stored in the engine-native .utex container bypass // MTKTextureLoader entirely and are uploaded directly to the GPU. @@ -705,7 +708,10 @@ public struct Material { if let rgbaImage = ctx.makeImage(), let texture = try? textureLoader.newTexture(cgImage: rgbaImage, options: options) { - Logger.log(message: "[UntoldTexture] Expanded grayscale \(label.lowercased()) to RGBA '\(runtimeMaterial.name ?? "")' \(texture.width)x\(texture.height)") + Logger.log( + message: "[UntoldTexture] Expanded grayscale \(label.lowercased()) to RGBA '\(runtimeMaterial.name ?? "")' \(texture.width)x\(texture.height)", + category: LogCategory.textureLoading.rawValue + ) return texture } } @@ -713,7 +719,10 @@ public struct Material { do { let texture = try textureLoader.newTexture(URL: url, options: options) - Logger.log(message: "[UntoldTexture] Loaded \(label.lowercased()) texture '\(runtimeMaterial.name ?? "")' \(texture.width)x\(texture.height)") + Logger.log( + message: "[UntoldTexture] Loaded \(label.lowercased()) texture '\(runtimeMaterial.name ?? "")' \(texture.width)x\(texture.height)", + category: LogCategory.textureLoading.rawValue + ) return texture } catch { handleError(.textureFailedLoading, "\(label) \(error.localizedDescription)", runtimeMaterial.name ?? "") @@ -1229,7 +1238,10 @@ final class TextureLoader { keySource = "obj-identity(named-no-bracket)" } let isHit = textureCache[cacheKey] != nil - Logger.log(message: "[TextureCache] \(isHit ? "HIT " : "MISS") key=\(cacheKeyURL.absoluteString) source=\(keySource) mdlTex=0x\(String(UInt(bitPattern: ObjectIdentifier(mdlTex)), radix: 16)) name='\(textureName)' map=\(mapType) isSRGB=\(isSRGB)") + Logger.log( + message: "[TextureCache] \(isHit ? "HIT " : "MISS") key=\(cacheKeyURL.absoluteString) source=\(keySource) mdlTex=0x\(String(UInt(bitPattern: ObjectIdentifier(mdlTex)), radix: 16)) name='\(textureName)' map=\(mapType) isSRGB=\(isSRGB)", + category: LogCategory.textureLoading.rawValue + ) } if let cached = textureCache[cacheKey] { @@ -1259,7 +1271,10 @@ final class TextureLoader { // skipped for large assets and the MDLTexture has no pixel data yet. // Ask the MDLTexture to lazily fetch its own data from the USDZ package and retry. // This loads only this one texture, not the entire asset. - Logger.log(message: "[TextureLoad] MDL path failed for '\(textureName)' — retrying with lazy hydration (\(initialError.localizedDescription))") + Logger.log( + message: "[TextureLoad] MDL path failed for '\(textureName)' — retrying with lazy hydration (\(initialError.localizedDescription))", + category: LogCategory.textureLoading.rawValue + ) if mdlTex.texelDataWithTopLeftOrigin(atMipLevel: 0, create: true) != nil, let retryTex = try? mtkLoader.newTexture(texture: mdlTex, options: options) { @@ -1275,7 +1290,10 @@ final class TextureLoader { outputSourceDimensions: &outputSourceDimensions ) } - Logger.log(message: "[TextureLoad] Lazy hydration also failed for '\(textureName)' — falling through to URL paths") + Logger.log( + message: "[TextureLoad] Lazy hydration also failed for '\(textureName)' — falling through to URL paths", + category: LogCategory.textureLoading.rawValue + ) handleError(.textureFailedLoading) } } @@ -1327,7 +1345,10 @@ final class TextureLoader { outputSourceDimensions: &outputSourceDimensions ) } - Logger.log(message: "[TextureLoad] USDZ package URL failed for '\(parsed.innerPath)' — falling through to remaining paths") + Logger.log( + message: "[TextureLoad] USDZ package URL failed for '\(parsed.innerPath)' — falling through to remaining paths", + category: LogCategory.textureLoading.rawValue + ) } // 1) Try as-is (absolute or already-resolved) diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift index 91450a1c..e799bd90 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift @@ -130,7 +130,10 @@ extension GeometryStreamingSystem { } } - Logger.log(message: "[HLOD] Tile '\(tileId)' HLOD loaded.") + Logger.log( + message: "[TileStreaming][HLOD] Tile '\(tileId)' HLOD loaded.", + category: LogCategory.tileStreaming.rawValue + ) } else { self.decrementHLODLoadCount() if scene.exists(capturedHlodId) { @@ -200,7 +203,10 @@ extension GeometryStreamingSystem { unmarkLoadedHLODEntity(entityId) recordTileRepresentationSwap(entityId: entityId, tileId: tileComp.tileId, representation: "hlod:unloaded") - Logger.log(message: "[HLOD] Tile '\(tileComp.tileId)' HLOD unloaded.") + Logger.log( + message: "[TileStreaming][HLOD] Tile '\(tileComp.tileId)' HLOD unloaded.", + category: LogCategory.tileStreaming.rawValue + ) } // MARK: - Per-tile LOD level load / unload @@ -309,7 +315,10 @@ extension GeometryStreamingSystem { BatchingSystem.shared.notifyTileEntitiesResident(renderIds) } } - Logger.log(message: "[LOD] Tile '\(tileId)' LOD level \(capturedIndex + 1) loaded.") + Logger.log( + message: "[TileStreaming][LOD] Tile '\(tileId)' LOD level \(capturedIndex + 1) loaded.", + category: LogCategory.tileStreaming.rawValue + ) } else { if scene.exists(capturedLodId) { destroyEntity(entityId: capturedLodId) @@ -390,7 +399,10 @@ extension GeometryStreamingSystem { } recordTileRepresentationSwap(entityId: entityId, tileId: tileComp.tileId, representation: "lod\(levelIndex + 1):unloaded") - Logger.log(message: "[LOD] Tile '\(tileComp.tileId)' LOD level \(levelIndex + 1) unloaded.") + Logger.log( + message: "[TileStreaming][LOD] Tile '\(tileComp.tileId)' LOD level \(levelIndex + 1) unloaded.", + category: LogCategory.tileStreaming.rawValue + ) } /// Unload every LOD level for a tile stub. Called when the full tile reaches @@ -426,7 +438,10 @@ extension GeometryStreamingSystem { let tileURL = tileComp.tileURL let tileId = tileComp.tileId - Logger.log(message: "[TileStreaming] Dispatching load for tile '\(tileId)'") + Logger.log( + message: "[TileStreaming] Dispatching load for tile '\(tileId)'", + category: LogCategory.tileStreaming.rawValue + ) // Create a dedicated mesh entity as a child of the tile stub before // spawning the load Task. setEntityMeshAsync will attach all geometry @@ -526,7 +541,10 @@ extension GeometryStreamingSystem { } unmarkLoadingTileEntity(entityId) unmarkLoadedTileEntity(entityId) - Logger.log(message: "[TileStreaming] Tile '\(tileId)' cancelled load cleaned up.") + Logger.log( + message: "[TileStreaming] Tile '\(tileId)' cancelled load cleaned up.", + category: LogCategory.tileStreaming.rawValue + ) return } @@ -595,7 +613,10 @@ extension GeometryStreamingSystem { let selectableSuffix = selectableRenderIds.isEmpty ? "" : " selectable=[\(selectableNames)]" - Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload, render=\(tileRenderIds.count), selectable=\(selectableRenderIds.count)). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)\(selectableSuffix)") + Logger.log( + message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload, render=\(tileRenderIds.count), selectable=\(selectableRenderIds.count)). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)\(selectableSuffix)", + category: LogCategory.tileStreaming.rawValue + ) } else { // Destroy the pre-created child entity on failure so it // doesn't leak as an empty, invisible stub. @@ -791,7 +812,10 @@ extension GeometryStreamingSystem { let unloadMs = (CFAbsoluteTimeGetCurrent() - unloadStart) * 1000.0 let unloadSuffix = unloadMs > 50 ? " ⚠️ slow=\(String(format: "%.0f", unloadMs))ms" : "" - Logger.log(message: "[TileStreaming] Tile '\(tileId)' unloaded (\(descendants.count) child entities destroyed).\(unloadSuffix)") + Logger.log( + message: "[TileStreaming] Tile '\(tileId)' unloaded (\(descendants.count) child entities destroyed).\(unloadSuffix)", + category: LogCategory.tileStreaming.rawValue + ) } } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index 68f88034..bf142b2d 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -657,7 +657,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { let bSys = BatchingSystem.shared.diagnosticSummary() let capturedPeak = peakTickMs peakTickMs = 0.0 - Logger.log(message: "[StreamingHB] t=\(Int(wallNow - sessionStartWallTime))s cam=(\(String(format: "%.1f", effectiveCameraPosition.x)),\(String(format: "%.1f", effectiveCameraPosition.y)),\(String(format: "%.1f", effectiveCameraPosition.z))) geom=\(bStats.meshMemoryUsed / (1024 * 1024))MB(\(bPct)%) tiles=\(tilesLoaded)loaded/\(tilesLoading)loading peakTickMs=\(String(format: "%.1f", capturedPeak)) shadowCasters=\(RenderPasses.lastShadowCasterCount) \(bSys)") + Logger.log( + message: "[StreamingHB] t=\(Int(wallNow - sessionStartWallTime))s cam=(\(String(format: "%.1f", effectiveCameraPosition.x)),\(String(format: "%.1f", effectiveCameraPosition.y)),\(String(format: "%.1f", effectiveCameraPosition.z))) geom=\(bStats.meshMemoryUsed / (1024 * 1024))MB(\(bPct)%) tiles=\(tilesLoaded)loaded/\(tilesLoading)loading peakTickMs=\(String(format: "%.1f", capturedPeak)) shadowCasters=\(RenderPasses.lastShadowCasterCount) \(bSys)", + category: LogCategory.streamingHeartbeat.rawValue + ) } let nearbyEntities = OctreeSystem.shared.queryNear(point: effectiveCameraPosition, radius: maxQueryRadius) @@ -769,7 +772,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { else { continue } Logger.logWarning( - message: "[TileStreaming] Tile '\(tc.tileId)' parse timed out after \(Int(tileParseTimeoutSeconds))s while state=\(String(describing: tc.state)) — forcing teardown." + message: "[TileStreaming] Tile '\(tc.tileId)' parse timed out after \(Int(tileParseTimeoutSeconds))s while state=\(String(describing: tc.state)) — forcing teardown.", + category: LogCategory.tileStreaming.rawValue ) tc.loadTask?.cancel() tc.loadTask = nil @@ -965,7 +969,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { if MemoryBudgetManager.shared.shouldEvictGeometry() { let tileEvicted = evictTileGeometry(cameraPosition: effectiveCameraPosition, maxEvictions: 2) if lruEvicted == 0, tileEvicted == 0 { - Logger.logWarning(message: "[TileStreaming] Geometry budget over threshold but no eviction candidates found — consider reducing scene size or shared bucket memory.") + Logger.logWarning( + message: "[TileStreaming] Geometry budget over threshold but no eviction candidates found — consider reducing scene size or shared bucket memory.", + category: LogCategory.tileStreaming.rawValue + ) } } } @@ -2147,7 +2154,8 @@ extension GeometryStreamingSystem { tileSwapWindow[entityId] = updated if updated.swaps == warningThreshold { Logger.logWarning( - message: "[TileStreaming] Swap thrash detected for tile '\(tileId)' — \(updated.swaps) representation changes in \(Int(windowSeconds))s (latest=\(representation))." + message: "[TileStreaming] Swap thrash detected for tile '\(tileId)' — \(updated.swaps) representation changes in \(Int(windowSeconds))s (latest=\(representation)).", + category: LogCategory.tileStreaming.rawValue ) withStateLock { diagnostics.tileSwapWarnings += 1 diff --git a/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift b/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift index aeae6231..f694540e 100644 --- a/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/TextureStreamingSystem.swift @@ -825,7 +825,10 @@ public class TextureStreamingSystem: @unchecked Sendable { } else { reservedUpgradeBytes = 0 budgetedWorkItems = workItems.filter { $0.direction == .downgrade } - Logger.log(message: "[TextureStreaming] entity=\(entityId) skipped \(workItems.filter { $0.direction == .upgrade }.count) upgrade(s) — budget full (need \(upgradeBytes.formattedAsMemory))") + Logger.log( + message: "[TextureStreaming] entity=\(entityId) skipped \(workItems.filter { $0.direction == .upgrade }.count) upgrade(s) — budget full (need \(upgradeBytes.formattedAsMemory))", + category: LogCategory.textureStreaming.rawValue + ) } } else { reservedUpgradeBytes = 0 @@ -1020,7 +1023,10 @@ public class TextureStreamingSystem: @unchecked Sendable { let dir = didUpgrade ? "↑" : "↓" let dim = targetMaxDimension.map { "\($0)px" } ?? "full" let distStr = distance >= 0 ? String(format: "%.1f", distance) : "offscreen" - print("[TextureStreaming] entity=\(entityId) \(dir) → \(dim) dist=\(distStr) visible=\(isVisible)") + Logger.log( + message: "[TextureStreaming] entity=\(entityId) \(dir) → \(dim) dist=\(distStr) visible=\(isVisible)", + category: LogCategory.textureStreaming.rawValue + ) } } } @@ -1127,14 +1133,20 @@ public class TextureStreamingSystem: @unchecked Sendable { guard let target = renderInfo.device.makeTexture(descriptor: desc) else { if verboseLogging { - print("[TextureStreaming] GPU resample failed: makeTexture returned nil (size: \(targetWidth)x\(targetHeight))") + Logger.log( + message: "[TextureStreaming] GPU resample failed: makeTexture returned nil (size: \(targetWidth)x\(targetHeight))", + category: LogCategory.textureStreaming.rawValue + ) } return nil } guard let commandBuffer = commandQueue.makeCommandBuffer() else { if verboseLogging { - print("[TextureStreaming] GPU resample failed: makeCommandBuffer returned nil") + Logger.log( + message: "[TextureStreaming] GPU resample failed: makeCommandBuffer returned nil", + category: LogCategory.textureStreaming.rawValue + ) } return nil } diff --git a/Sources/UntoldEngine/Utils/Logger.swift b/Sources/UntoldEngine/Utils/Logger.swift index 7d7ffc8d..f1ac3eec 100644 --- a/Sources/UntoldEngine/Utils/Logger.swift +++ b/Sources/UntoldEngine/Utils/Logger.swift @@ -26,6 +26,10 @@ public enum LogCategory: String, CaseIterable, Sendable { case oocTiming = "OOCTiming" case oocStatus = "OOCStatus" case assetLoader = "AssetLoader" + case tileStreaming = "TileStreaming" + case streamingHeartbeat = "StreamingHeartbeat" + case textureStreaming = "TextureStreaming" + case textureLoading = "TextureLoading" case engineStats = "EngineStats" case integration = "Integration" case xrCamera = "XRCamera" @@ -187,6 +191,10 @@ public enum Logger { LogCategory.oocTiming.rawValue, LogCategory.oocStatus.rawValue, LogCategory.assetLoader.rawValue, + LogCategory.tileStreaming.rawValue, + LogCategory.streamingHeartbeat.rawValue, + LogCategory.textureStreaming.rawValue, + LogCategory.textureLoading.rawValue, LogCategory.xrCamera.rawValue, ] private var categoryOverrides: [String: Bool] = [:] diff --git a/Tests/UntoldEngineTests/LoggerCategoryTests.swift b/Tests/UntoldEngineTests/LoggerCategoryTests.swift new file mode 100644 index 00000000..bd383cf3 --- /dev/null +++ b/Tests/UntoldEngineTests/LoggerCategoryTests.swift @@ -0,0 +1,39 @@ +// +// LoggerCategoryTests.swift +// UntoldEngineTests +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +final class LoggerCategoryTests: XCTestCase { + override func tearDown() { + Logger.resetCategoryToggles() + super.tearDown() + } + + func testHighVolumeStreamingCategoriesAreDisabledByDefault() { + Logger.resetCategoryToggles() + + XCTAssertFalse(Logger.isEnabled(category: .tileStreaming)) + XCTAssertFalse(Logger.isEnabled(category: .streamingHeartbeat)) + XCTAssertFalse(Logger.isEnabled(category: .textureStreaming)) + XCTAssertFalse(Logger.isEnabled(category: .textureLoading)) + } + + func testStreamingCategoriesCanBeEnabledIndividually() { + Logger.resetCategoryToggles() + + Logger.enable(category: .tileStreaming) + + XCTAssertTrue(Logger.isEnabled(category: .tileStreaming)) + XCTAssertFalse(Logger.isEnabled(category: .textureStreaming)) + XCTAssertFalse(Logger.isEnabled(category: .textureLoading)) + XCTAssertFalse(Logger.isEnabled(category: .streamingHeartbeat)) + } +} diff --git a/docs/API/UsingTheLogger.md b/docs/API/UsingTheLogger.md index 8884e05c..70d770a1 100644 --- a/docs/API/UsingTheLogger.md +++ b/docs/API/UsingTheLogger.md @@ -35,6 +35,8 @@ Logger.log(message: "Scene loaded successfully", category: LogCategory.general.r Requires `logLevel >= .info`. Suppressed if the category is disabled. +Console output is prefixed with `Log:`. + ### Warnings ```swift @@ -43,6 +45,8 @@ Logger.logWarning(message: "Mesh has no UV channel", category: LogCategory.gener Requires `logLevel >= .warning`. Always emits regardless of category state. +Console output is prefixed with `Warning:`. + ### Errors ```swift @@ -51,24 +55,39 @@ Logger.logError(message: "Failed to load texture: \(name)", category: LogCategor Requires `logLevel >= .error`. Always emits regardless of category state. +Console output is prefixed with `Error:`. + +### Debug vectors + +```swift +Logger.log(vector: simd_float3(1, 2, 3), category: LogCategory.general.rawValue) +Logger.log(message: "Camera position", vector: position, category: LogCategory.xrCamera.rawValue) +``` + +Vector helpers require `logLevel >= .debug` and are suppressed if the category is disabled. + > **Note:** Messages are lazily evaluated (`@autoclosure`), so string interpolation cost is skipped when the log would be suppressed. ## Log Categories Categories let you silence or focus specific subsystems without changing the global log level. -| Category | Raw value | Default state | -|-----------------|----------------|---------------| -| `.general` | `"General"` | enabled | -| `.ecs` | `"ECS"` | enabled | -| `.engineStats` | `"EngineStats"`| enabled | -| `.integration` | `"Integration"`| enabled | -| `.xrCamera` | `"XRCamera"` | disabled | -| `.oocTiming` | `"OOCTiming"` | disabled | -| `.oocStatus` | `"OOCStatus"` | disabled | -| `.assetLoader` | `"AssetLoader"`| disabled | - -High-volume categories (`xrCamera`, `oocTiming`, `oocStatus`, `assetLoader`) are off by default to avoid log spam during normal operation. +| Category | Raw value | Default state | +|-----------------------|------------------------|---------------| +| `.general` | `"General"` | enabled | +| `.ecs` | `"ECS"` | enabled | +| `.engineStats` | `"EngineStats"` | enabled | +| `.integration` | `"Integration"` | enabled | +| `.xrCamera` | `"XRCamera"` | disabled | +| `.oocTiming` | `"OOCTiming"` | disabled | +| `.oocStatus` | `"OOCStatus"` | disabled | +| `.assetLoader` | `"AssetLoader"` | disabled | +| `.tileStreaming` | `"TileStreaming"` | disabled | +| `.streamingHeartbeat` | `"StreamingHeartbeat"` | disabled | +| `.textureStreaming` | `"TextureStreaming"` | disabled | +| `.textureLoading` | `"TextureLoading"` | disabled | + +High-volume categories (`xrCamera`, `oocTiming`, `oocStatus`, `assetLoader`, `tileStreaming`, `streamingHeartbeat`, `textureStreaming`, `textureLoading`) are off by default to avoid log spam during normal operation. ## Enabling and Disabling Categories @@ -92,7 +111,9 @@ Logger.resetCategoryToggles() ### Typical debug session ```swift -// Turn on verbose streaming traces for a debug session +// Turn on verbose geometry streaming traces for a debug session +Logger.enable(category: .tileStreaming) +Logger.enable(category: .streamingHeartbeat) Logger.enable(category: .oocStatus) Logger.enable(category: .oocTiming) Logger.enable(category: .assetLoader) @@ -100,11 +121,20 @@ Logger.enable(category: .assetLoader) // ... reproduce the issue ... // Clean up after capture +Logger.disable(category: .tileStreaming) +Logger.disable(category: .streamingHeartbeat) Logger.disable(category: .oocStatus) Logger.disable(category: .oocTiming) Logger.disable(category: .assetLoader) ``` +Texture diagnostics can be enabled separately: + +```swift +Logger.enable(category: .textureStreaming) +Logger.enable(category: .textureLoading) +``` + ## Adding a Custom Sink Implement `LoggerSink` to route events to a custom destination such as an editor console or file: