diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f31a2dc..f80ad27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,28 @@ # Changelog +## v0.12.13 - 2026-05-21 +### 🐞 Fixes +- [Patch] Added AA parameters to serializer (3d81b55…) +- [Patch] Set ambient default value to 0.4 (c5a72a2…) +- [Patch] Use recursive traversal for derived asset node ids in serialization (41acc07…) +- [Patch] Route native OCC stubs through child entities (e1b2935…) +- [Patch] Reimplented setEntityMesh (6250f0a…) +- [Patch] Added radii to structured content to stream system (5392935…) +- [Patch] Stabilize tiled streaming world mutations (e3436fd…) +- [Patch] Guard floor-proximity gate on interior tiles only (ec4a36c…) +- [Patch] Enforce minimum residency for parsed tiles before unload or eviction (224d528…) +- [Patch] [Feature] Replace radius/distance importance with tile view importance (291a626…) +- [Patch] Add screen-space AABB occlusion factor to tile importance sort (96f1b39…) +- [Patch] Spread tile parse-completion batching work across frames (c5f3c50…) +- [Patch] Skip near-plane-clipping tiles as occluders in screen-space occlusion sort (751398b…) +- [Patch] Disable occlusion sort when no active camera is available (c378cb1…) +- [Patch] Replace additive occlusion coverage with 8Γ—8 grid union bitmask (9d3bfac…) +- [Patch] Use closest AABB point for view alignment instead of tile center (d72106a…) +- [Patch] Floor occlusionScore at occlusionMinWeight to prevent hard load suppression (abd3dbd…) +- [Patch] Harden tile-resident drain queue in BatchingSystem (2b5713c…) +- [Patch] Guard rectToScreenMask against zero-area rects (ecc8d46…) +- [Patch] Route tile-resident drain entities through quiescence to prevent repeated cell rebuilds (aec4112…) +### πŸ“š Docs +- [Docs] Updated streaming docs (ed14520…) ## v0.12.12 - 2026-05-18 ### 🐞 Fixes - [Patch] Replace FXAA toggle with anti-aliasing mode selector (0111454…) diff --git a/README.md b/README.md index 89dbeb24..ea99f216 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.12.12 +git checkout v0.12.13 swift run untolddemo ``` diff --git a/Sources/DemoGame/AppDelegate.swift b/Sources/DemoGame/AppDelegate.swift index b4d27e04..cea7785d 100644 --- a/Sources/DemoGame/AppDelegate.swift +++ b/Sources/DemoGame/AppDelegate.swift @@ -11,7 +11,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.12.12" + static let appVersion = "0.12.13" static let defaultWindowSize = NSSize(width: 1920, height: 1080) static let minimumWindowSize = NSSize(width: 640, height: 480) } diff --git a/Sources/DemoGame/GameScene.swift b/Sources/DemoGame/GameScene.swift index b3eaddf2..1cdd5e20 100644 --- a/Sources/DemoGame/GameScene.swift +++ b/Sources/DemoGame/GameScene.swift @@ -31,7 +31,7 @@ private enum Constants { static let orbitTargetOffset: Float = 25.0 - static let cameraMoveSpeed: Float = 1.0 + static let cameraMoveSpeed: Float = 0.5 static let cameraInputDeltaTime: Float = 0.1 static let streamingPriority: Int = 10 static let citySceneID = "city" @@ -76,6 +76,8 @@ applyIBL = true renderEnvironment = false + + // setEngineStatsLogging(enabled: true, profile: .verbose, intervalSeconds: 1.0) } } diff --git a/Sources/Sandbox/AppDelegate.swift b/Sources/Sandbox/AppDelegate.swift index c9ae173a..7bfec41f 100644 --- a/Sources/Sandbox/AppDelegate.swift +++ b/Sources/Sandbox/AppDelegate.swift @@ -10,7 +10,7 @@ @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private enum Constants { - static let appVersion = "0.12.12" + static let appVersion = "0.12.13" static let windowSize = NSSize(width: 1600, height: 900) } diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index 6e36dcbd..14fbd977 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -496,6 +496,22 @@ public class TileComponent: Component { /// tiles on the camera being inside the scene's interior_zone. public var isInterior: Bool = false + /// True when this tile came from a floor-aware manifest entry. + /// The floor-proximity gate must key off this explicit metadata, not a nonzero + /// Y center, because older uniform-grid manifests can have large padded Y bounds. + public var hasFloorMetadata: Bool = false + + /// Floor index from the manifest. 0 is a valid ground-floor value when + /// `hasFloorMetadata` is true. + /// Used by the floor-proximity gate in GeometryStreamingSystem to skip tiles + /// whose floor band is vertically distant from the camera. + public var floorId: Int = 0 + + /// Y-axis centre of the tile's world-space AABB. + /// Stored at registration time so the floor-proximity gate can skip vertically + /// distant tiles without a repeated LocalTransformComponent lookup per tick. + public var worldYCenter: Float = 0 + /// Asset-level lifecycle state driven by the streaming bootstrap. public var state: TileAssetState = .unloaded @@ -526,6 +542,12 @@ public class TileComponent: Component { /// re-dispatched, preventing tight load-cancel cycles at the boundary. public var pendingUnloadSince: CFAbsoluteTime = 0 + /// CFAbsoluteTime at which this tile most recently became `.parsed`. + /// Used by the streaming system to enforce a minimum resident dwell so a + /// newly visible full tile cannot be immediately torn down when the camera + /// hovers near an unload boundary. + public var parsedResidentSince: CFAbsoluteTime = 0 + // MARK: - Visual Readiness (OCC sub-mesh tracking) /// Total number of OCC (out-of-core) streaming stubs created during tile parse. diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index d8a9f553..25b1695e 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -15,6 +15,9 @@ import Metal import MetalKit public enum RenderPasses { + /// Count of unbatched shadow-caster entities from the most recent frame. + /// Updated by shadowCasterEntityIds (main thread); read by the streaming heartbeat (main thread). + public nonisolated(unsafe) static var lastShadowCasterCount: Int = 0 public typealias RenderPassExecution = @Sendable (MTLCommandBuffer) -> Void private static let runtimeState = RuntimeState() @@ -350,7 +353,16 @@ public enum RenderPasses { for entityId in entities { if shouldSkipShadowEntity(entityId) { continue } - if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { continue } + if BatchingSystem.shared.isEnabled() { + // Batch-eligible entities (StaticBatchComponent present) are always drawn + // via shadowCasterBatchGroups β€” whether or not the batch rebuild has landed + // yet. Skipping them here prevents O(n_loaded_tiles) shadow draw calls that + // scale with the scene and eventually overflow the GPU command buffer budget. + // During the brief batch-rebuild window their shadow is absent; this is + // preferable to the alternative of the app freezing at ~300+ loaded tiles. + if scene.get(component: StaticBatchComponent.self, for: entityId) != nil { continue } + if BatchingSystem.shared.isBatched(entityId: entityId) { continue } + } guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), renderComponent.isVisible, @@ -368,6 +380,7 @@ public enum RenderPasses { } } + lastShadowCasterCount = result.count return result } diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 762c30f4..c53427ae 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -175,7 +175,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { CameraSystem.shared.activeCamera = gameCamera - Logger.log(message: "Untold Engine Starting. Version 0.12.12") + Logger.log(message: "Untold Engine Starting. Version 0.12.13") } public func initSizeableResources() { @@ -326,6 +326,8 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } #endif EngineProfiler.shared.beginFrame() + lockWorldAccessGate() + defer { unlockWorldAccessGate() } // finalize destroys once per frame if needsFinalizeDestroys { diff --git a/Sources/UntoldEngine/Systems/AssetLoadingState.swift b/Sources/UntoldEngine/Systems/AssetLoadingState.swift index 856966b9..7cb9dfb5 100644 --- a/Sources/UntoldEngine/Systems/AssetLoadingState.swift +++ b/Sources/UntoldEngine/Systems/AssetLoadingState.swift @@ -85,20 +85,50 @@ public final class AssetLoadingGate: @unchecked Sendable { } } -/// Execute a world-mutation critical section while pausing XR scene traversal. +private final class WorldAccessGate: @unchecked Sendable { + static let shared = WorldAccessGate() + + private let lock = NSRecursiveLock() + + private init() {} + + func sync(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } + + func lockAccess() { + lock.lock() + } + + func unlockAccess() { + lock.unlock() + } +} + +/// Execute a world/ECS access critical section without changing render loading state. @inline(__always) -public func withWorldMutationGate(_ body: () throws -> T) rethrows -> T { - AssetLoadingGate.shared.beginLoading() - defer { AssetLoadingGate.shared.finishLoading() } - return try body() +public func withWorldAccessGate(_ body: () throws -> T) rethrows -> T { + try WorldAccessGate.shared.sync(body) } -/// Async variant for world-mutation critical sections. @inline(__always) -public func withWorldMutationGate(_ body: () async throws -> T) async rethrows -> T { +public func lockWorldAccessGate() { + WorldAccessGate.shared.lockAccess() +} + +@inline(__always) +public func unlockWorldAccessGate() { + WorldAccessGate.shared.unlockAccess() +} + +/// Execute a world-mutation critical section while pausing XR scene traversal. +@inline(__always) +public func withWorldMutationGate(_ body: () throws -> T) rethrows -> T { AssetLoadingGate.shared.beginLoading() defer { AssetLoadingGate.shared.finishLoading() } - return try await body() + return try WorldAccessGate.shared.sync(body) } /// Loading phase enum diff --git a/Sources/UntoldEngine/Systems/BatchingSystem.swift b/Sources/UntoldEngine/Systems/BatchingSystem.swift index 41d8f24a..28406cd5 100644 --- a/Sources/UntoldEngine/Systems/BatchingSystem.swift +++ b/Sources/UntoldEngine/Systems/BatchingSystem.swift @@ -464,10 +464,28 @@ public class BatchingSystem: @unchecked Sendable { private var pendingEntityRemovals: Set = [] private var pendingEntityAdditions: Set = [] private var newlyResidentEntities: Set = [] - /// Entities that arrived from a fully-loaded tile parse (occCount == 0). - /// These are processed with deferBatchBuild = false and their cells are - /// promoted to batchPending immediately, bypassing the quiescence delay. + /// Entities registered via notifyTileParsedEntities (legacy direct path). + /// notifyTileEntitiesResident no longer pre-populates this set β€” drain-queue + /// entities go through the normal quiescence path so that multiple drain slices + /// for the same cell coalesce into a single rebuild rather than rebuilding once + /// per slice. private var tileParsedEntityIds: Set = [] + /// FIFO queue of tile-resident entities waiting for per-entity cell registration. + /// Populated by notifyTileEntitiesResident; drained at maxTileResidentDrainPerTick + /// entities per batch tick. Spreading this work across frames prevents the + /// withWorldMutationGate stall that occurred when all N entities of a large + /// fullLoad tile were registered in one shot at parse-completion time. + private var pendingTileResidentQueue: [EntityID] = [] + /// Logical start of pendingTileResidentQueue. Advancing this instead of calling + /// removeFirst(k) avoids O(remaining) element-shift cost on every drain. + /// The array is compacted (old head entries freed) once the head passes the midpoint. + private var pendingTileResidentQueueHead: Int = 0 + /// Maximum entities drained from pendingTileResidentQueue per batch tick. + /// Higher values register tile geometry faster but may spike batch-tick cost. + /// Default 16: a 32-entity tile registers over 2 ticks (~33 ms at 60 fps). + /// Values < 1 are clamped to 1 at the drain site β€” 0 or negative would either + /// silently stall the queue or crash on Array.removeFirst with a negative count. + public var maxTileResidentDrainPerTick: Int = 16 private var isSubscribed: Bool = false private var batchCellSize: Float = 32.0 private var cellLifecycle: [BatchCellID: BatchCellLifecycleRecord] = [:] @@ -488,7 +506,7 @@ public class BatchingSystem: @unchecked Sendable { private var backgroundArtifactBuildEnabled: Bool = true private var maxBuildDispatchesPerTick: Int = 4 private var maxArtifactAppliesPerTick: Int = 4 - private var lastTickDiagnostics: BatchingTickDiagnostics = .init() + private(set) var lastTickDiagnostics: BatchingTickDiagnostics = .init() private var batchIndexNeedsRebuild: Bool = false private var tickCounter: Int = 0 private var cellLastVisibleFrame: [BatchCellID: Int] = [:] @@ -535,40 +553,90 @@ public class BatchingSystem: @unchecked Sendable { } /// Batch-notifies the batching system that all entities in `entityIds` are now - /// resident, combining what was previously a per-entity `AssetResidencyChangedEvent` - /// storm + a separate `notifyTileParsedEntities` call into a single synchronous batch. - /// - /// This replaces the `queueResidencyEventsForRenderDescendants` + - /// `notifyTileParsedEntities` pairing at tile/LOD/HLOD load completion sites. + /// resident. Per-entity cell registration is deferred to the batch tick drain + /// (pendingTileResidentQueue) so the withWorldMutationGate hold at tile + /// parse-completion time is reduced to a cheap Set union + Array append, removing + /// the O(N) cell-resolution spike that caused visible frame stalls on large tiles. public func notifyTileEntitiesResident(_ entityIds: Set) { guard batchingEnabled else { return } guard !entityIds.isEmpty else { return } - // Mark all IDs as tile-parsed so the quiescence delay is bypassed. - tileParsedEntityIds.formUnion(entityIds) - - for entityId in entityIds { - guard scene.exists(entityId) else { continue } - let hasStaticBatch = scene.get(component: StaticBatchComponent.self, for: entityId) != nil - guard hasStaticBatch else { continue } - pendingEntityAdditions.insert(entityId) - newlyResidentEntities.insert(entityId) - if let cellId = resolveCellIdForEntity(entityId: entityId) { - markCellStreaming(cellId) - } - } + // Enqueue for deferred per-entity registration β€” O(N) appends, no ECS work. + // Entities are intentionally NOT pre-marked in tileParsedEntityIds here. + // Doing so caused drain slices to bypass quiescence and promote their cell + // to batchPending immediately, triggering a batch rebuild per slice. For a + // 30-entity tile drained at 16/tick, that was 2 rebuilds instead of 1. + // Without the pre-mark, drained entities enter via deferBatchBuild = true β†’ + // normal quiescence path. Subsequent slices for the same cell arrive within + // the 1-frame quiescence window and reset the timer, so the cell rebuilds + // once after the last slice β€” regardless of how many slices the tile produced. + pendingTileResidentQueue.append(contentsOf: entityIds) } /// Removes the given entities from all pending batching queues immediately. /// Call this before destroying LOD/HLOD child entities so their queued additions /// are never processed after the entity is gone, eliminating "entity is missing" /// errors and wasted batch rebuilds on the next tick. + /// + /// Only cancels *pending* state β€” entities already committed to `entityToCellMembership` + /// by a previous batch tick are not removed. Use `notifyTileEntitiesUnloading` when + /// destroying tile geometry to also clean committed state. public func cancelPendingEntities(_ entityIds: Set) { guard !entityIds.isEmpty else { return } pendingEntityAdditions.subtract(entityIds) pendingEntityRemovals.subtract(entityIds) newlyResidentEntities.subtract(entityIds) tileParsedEntityIds.subtract(entityIds) + // Rebuild from the unprocessed tail only; already-drained entries (before + // head) don't need to be scanned and the rebuild resets the head to 0. + if pendingTileResidentQueueHead < pendingTileResidentQueue.count { + let tail = pendingTileResidentQueue[pendingTileResidentQueueHead...] + pendingTileResidentQueue = tail.filter { !entityIds.contains($0) } + } else { + pendingTileResidentQueue.removeAll(keepingCapacity: true) + } + pendingTileResidentQueueHead = 0 + } + + /// Full batching cleanup for tile entities that are about to be destroyed. + /// + /// Handles both cases: + /// - Entities still in the *pending* queue (batch tick hasn't run yet): removes + /// them so the addition is never processed on a destroyed entity. + /// - Entities already *committed* to `entityToCellMembership` (a previous batch tick + /// ran): queues them for removal so stale map entries are cleared before entity IDs + /// are recycled by the ECS. Without this, a new tile whose mesh entity gets a + /// recycled ID lands in the wrong batch cell, corrupting batch state and causing + /// "entity is missing" errors when the next batch tick processes the stale entry. + public func notifyTileEntitiesUnloading(_ entityIds: Set) { + guard !entityIds.isEmpty else { return } + // Cancel any pending additions β€” prevents processing additions on dead entities. + pendingEntityAdditions.subtract(entityIds) + newlyResidentEntities.subtract(entityIds) + tileParsedEntityIds.subtract(entityIds) + // Flush unprocessed entries from the drain queue so destroyed entity IDs + // are never drained. Rebuild from the unprocessed tail and reset the head. + if pendingTileResidentQueueHead < pendingTileResidentQueue.count { + let tail = pendingTileResidentQueue[pendingTileResidentQueueHead...] + pendingTileResidentQueue = tail.filter { !entityIds.contains($0) } + } else { + pendingTileResidentQueue.removeAll(keepingCapacity: true) + } + pendingTileResidentQueueHead = 0 + // Queue removal from committed state. removeEntityFromBatchingTracking is called + // by the batch tick; it only manipulates BatchingSystem-internal dictionaries and + // does not access ECS data, so it is safe to call after destroyEntity. + pendingEntityRemovals.formUnion(entityIds) + } + + /// Compact one-line summary for periodic heartbeat logging. + /// Reports the fields most likely to reveal accumulation bugs: + /// registered entity count, dirty cells, and last rebuild cost. + public func diagnosticSummary() -> String { + let d = lastTickDiagnostics + let q = pendingTileResidentQueue.count - pendingTileResidentQueueHead + let qStr = q > 0 ? " residentQueue=\(q)" : "" + return "batch: registered=\(entityToCellMembership.count) dirty=\(d.dirtyCellsBeforePrune) rebuildMs=\(String(format: "%.1f", d.rebuildWorkMs)) groups=\(batchGroups.count)\(qStr)" } private func handleLODChange(_ event: EntityLODChangedEvent) { @@ -706,6 +774,43 @@ public class BatchingSystem: @unchecked Sendable { lastTickDiagnostics = diagnostics return } + + // Drain deferred tile-resident entities at a controlled rate. + // notifyTileEntitiesResident enqueues entity IDs here instead of processing + // them immediately inside withWorldMutationGate. Each tick we take at most + // maxTileResidentDrainPerTick entries, do the per-entity ECS and cell work, + // and insert them into pendingEntityAdditions for the normal processing below. + if pendingTileResidentQueueHead < pendingTileResidentQueue.count { + // Clamp to >= 1: 0 stalls the queue; negatives crash removeFirst. + let safeDrain = max(1, maxTileResidentDrainPerTick) + let available = pendingTileResidentQueue.count - pendingTileResidentQueueHead + let drainEnd = pendingTileResidentQueueHead + min(safeDrain, available) + + for i in pendingTileResidentQueueHead ..< drainEnd { + let entityId = pendingTileResidentQueue[i] + guard scene.exists(entityId) else { continue } + guard scene.get(component: StaticBatchComponent.self, for: entityId) != nil else { continue } + pendingEntityAdditions.insert(entityId) + newlyResidentEntities.insert(entityId) + if let cellId = resolveCellIdForEntity(entityId: entityId) { + markCellStreaming(cellId) + } + } + + pendingTileResidentQueueHead = drainEnd + + // Compact: free dead-head entries when fully drained or head is past + // the midpoint. Both paths are at most O(remaining), but the midpoint + // trigger means each element causes at most one copy β€” amortised O(1). + if pendingTileResidentQueueHead >= pendingTileResidentQueue.count { + pendingTileResidentQueue.removeAll(keepingCapacity: true) + pendingTileResidentQueueHead = 0 + } else if pendingTileResidentQueueHead > pendingTileResidentQueue.count / 2 { + pendingTileResidentQueue = Array(pendingTileResidentQueue[pendingTileResidentQueueHead...]) + pendingTileResidentQueueHead = 0 + } + } + guard !pendingEntityRemovals.isEmpty || !pendingEntityAdditions.isEmpty || !dirtyCells.isEmpty || @@ -723,9 +828,11 @@ public class BatchingSystem: @unchecked Sendable { pendingEntityRemovals.removeAll(keepingCapacity: true) // Process additions / cell updates based on latest entity state. - // Tile-parsed entities (from a just-loaded fullLoad tile) bypass the - // quiescence delay: we clear them from newlyResidentEntities so - // deferBatchBuild stays false, and track them for immediate promotion below. + // Drain-queue entities arrive with deferBatchBuild = true (they are in + // newlyResidentEntities but NOT in tileParsedEntityIds) so they go through + // the normal quiescence path. The tileParsedEntityIds fast path is kept for + // any future caller of notifyTileParsedEntities but is not triggered by + // notifyTileEntitiesResident, which deliberately omits the pre-mark. var tileEntitiesAdded: Set = [] for entityId in pendingEntityAdditions { let isTileParsed = tileParsedEntityIds.contains(entityId) @@ -1928,6 +2035,8 @@ public class BatchingSystem: @unchecked Sendable { pendingEntityAdditions.removeAll() newlyResidentEntities.removeAll() tileParsedEntityIds.removeAll() + pendingTileResidentQueue.removeAll() + pendingTileResidentQueueHead = 0 cellLastVisibleFrame.removeAll() cellBuildGeneration.removeAll() runtimeBatchIneligibleCells.removeAll() @@ -1985,6 +2094,9 @@ public class BatchingSystem: @unchecked Sendable { pendingEntityRemovals.removeAll() pendingEntityAdditions.removeAll() newlyResidentEntities.removeAll() + tileParsedEntityIds.removeAll() + pendingTileResidentQueue.removeAll() + pendingTileResidentQueueHead = 0 runtimeBatchIneligibleCells.removeAll() clearPendingBuildArtifacts() for cellId in cellToEntities.keys { diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+Eviction.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+Eviction.swift index d8c99c6e..295e3747 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+Eviction.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+Eviction.swift @@ -101,6 +101,13 @@ extension GeometryStreamingSystem { /// while never entering loadedStreamingEntities. When the geometry budget is blocked by /// those representations, mesh-only eviction makes no progress and the streaming system /// can stall under permanent pressure. + /// + /// Protection floor: each tile's own `streamingRadius`, not the global + /// `visibleEvictionProtectionRadius`. A tile beyond its streaming radius is in the + /// hysteresis zone β€” the load pass will not re-dispatch it until the camera re-enters + /// `effectivePrefetchRadius`, so evicting it here does not cause immediate thrashing. + /// Tiles AT or INSIDE their streaming radius are protected because they would be + /// re-queued on the very next streaming tick. func evictTileGeometry(cameraPosition: simd_float3, maxEvictions: Int = Int.max) -> Int { enum TileEvictionKind { case fullTile @@ -110,6 +117,7 @@ extension GeometryStreamingSystem { var candidates: [(entityId: EntityID, kind: TileEvictionKind, distance: Float)] = [] var seen: Set = [] + let now = CFAbsoluteTimeGetCurrent() for entityId in loadedTileEntitiesSnapshot() { guard scene.exists(entityId), @@ -117,7 +125,12 @@ extension GeometryStreamingSystem { tileComp.state == .parsed else { continue } let distance = calculateDistance(entityId: entityId, cameraPosition: cameraPosition) - guard distance >= visibleEvictionProtectionRadius else { continue } + // Protect tiles within their own streaming radius β€” they would be re-dispatched + // immediately, causing a load/evict cycle that wastes bandwidth and CPU. + guard distance > tileComp.streamingRadius else { continue } + guard tileComp.parsedResidentSince == 0 || + now - tileComp.parsedResidentSince >= minimumParsedTileResidentSeconds + else { continue } candidates.append((entityId, .fullTile, distance)) seen.insert(entityId) } @@ -129,7 +142,7 @@ extension GeometryStreamingSystem { tileComp.hlodState == .loaded else { continue } let distance = calculateDistance(entityId: entityId, cameraPosition: cameraPosition) - guard distance >= visibleEvictionProtectionRadius else { continue } + guard distance > tileComp.streamingRadius else { continue } candidates.append((entityId, .hlod, distance)) seen.insert(entityId) } @@ -141,7 +154,7 @@ extension GeometryStreamingSystem { tileComp.lodLevels.contains(where: { $0.state == .loaded }) else { continue } let distance = calculateDistance(entityId: entityId, cameraPosition: cameraPosition) - guard distance >= visibleEvictionProtectionRadius else { continue } + guard distance > tileComp.streamingRadius else { continue } candidates.append((entityId, .lod, distance)) } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift index 89a9f552..b20727ac 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+TileStreaming.swift @@ -173,7 +173,7 @@ extension GeometryStreamingSystem { if let hlodId = capturedHlodEntityId { let renderIds = collectRenderDescendantIds(hlodId) if !renderIds.isEmpty { - BatchingSystem.shared.cancelPendingEntities(renderIds) + BatchingSystem.shared.notifyTileEntitiesUnloading(renderIds) TextureStreamingSystem.shared.cancelEntities(renderIds) } } @@ -358,7 +358,7 @@ extension GeometryStreamingSystem { if capturedLodEntityId != .invalid { let renderIds = collectRenderDescendantIds(capturedLodEntityId) if !renderIds.isEmpty { - BatchingSystem.shared.cancelPendingEntities(renderIds) + BatchingSystem.shared.notifyTileEntitiesUnloading(renderIds) TextureStreamingSystem.shared.cancelEntities(renderIds) } } @@ -543,6 +543,7 @@ extension GeometryStreamingSystem { tc.uploadedOCCStubs = 0 tc.failureCount = 0 // clear retry counter on successful parse tc.state = .parsed + tc.parsedResidentSince = CFAbsoluteTimeGetCurrent() self.markLoadedTileEntity(entityId) self.recordTileRepresentationSwap(entityId: entityId, tileId: tileId, representation: "tile:parsed") @@ -579,7 +580,9 @@ extension GeometryStreamingSystem { } } - Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload).") + let budgetStats = MemoryBudgetManager.shared.getStats() + let geomPct = Int((budgetStats.geometryUtilization * 100).rounded()) + Logger.log(message: "[TileStreaming] Tile '\(tileId)' parsed (\(occCount) OCC stubs pending GPU upload). geom=\(budgetStats.meshMemoryUsed / (1024 * 1024))MB/\(budgetStats.geometryBudget / (1024 * 1024))MB (\(geomPct)%)") } else { // Destroy the pre-created child entity on failure so it // doesn't leak as an empty, invisible stub. @@ -696,6 +699,7 @@ extension GeometryStreamingSystem { let tileId = tileComp.tileId let wasParsing = tileComp.state == .parsing + let unloadStart = CFAbsoluteTimeGetCurrent() withWorldMutationGate { // Mark as unloading to prevent the load pass from re-dispatching this tick. @@ -734,11 +738,16 @@ extension GeometryStreamingSystem { let descendants = collectTileDescendants(entityId) - // Cancel in-flight texture ops and bulk-remove stale upgradedEntities - // entries before destroying β€” prevents lazy cleanup accumulation and - // stops new texture upgrade ops from being scheduled on dying entities. + // Full batching + texture cleanup before destroying. + // notifyTileEntitiesUnloading handles BOTH pending-queue entries (entities + // not yet committed by the batch tick) AND committed entityToCellMembership + // entries (entities already processed by a previous batch tick). Without the + // committed-state cleanup, destroyed entity IDs that get recycled by the ECS + // land in the wrong batch cell, causing "entity is missing" errors and meshes + // that appear as green AABBs but never render. let renderDescendants = collectRenderDescendantIds(entityId) if !renderDescendants.isEmpty { + BatchingSystem.shared.notifyTileEntitiesUnloading(renderDescendants) TextureStreamingSystem.shared.cancelEntities(renderDescendants) } @@ -760,13 +769,16 @@ extension GeometryStreamingSystem { tileComp.totalOCCStubs = 0 tileComp.uploadedOCCStubs = 0 tileComp.pendingUnloadSince = 0 + tileComp.parsedResidentSince = 0 // Reset stub so the next approach triggers a fresh loadTile() call. tileComp.state = .unloaded tileComp.parseStartTime = 0 unmarkLoadedTileEntity(entityId) - Logger.log(message: "[TileStreaming] Tile '\(tileId)' unloaded (\(descendants.count) child entities destroyed).") + 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)") } } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index 20cbd41e..73c16419 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -36,6 +36,19 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// Maximum radius to query from octree (should cover largest unload radius) public var maxQueryRadius: Float = 500.0 + /// Maximum Y-axis distance between the camera and a tile's world-space Y centre + /// before the tile is excluded from streaming consideration. + /// + /// For multi-floor buildings this prevents all 10 floors from being simultaneous + /// load/unload candidates when the camera is on a single floor. Without this gate + /// every floor transition causes O(floors Γ— tiles_per_floor) simultaneous unloads + /// β€” a spike that starves the render loop and causes the Metal command-buffer + /// semaphore to block. + /// + /// Default 5 m β‰ˆ the current floor plus immediate vertical neighbours. + /// Set to Float.greatestFiniteMagnitude to disable (open-world scenes with no floors). + public var floorProximityGateY: Float = 5.0 + // MARK: - Near-Band Concurrency /// Fraction of an entity's streamingRadius that defines the "near band". @@ -136,6 +149,12 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// geometry and are cancelled immediately when out of range. public var unloadGracePeriod: Float = 3.0 + /// Minimum seconds a parsed full tile remains resident before normal unload + /// or geometry-pressure eviction may tear it down. This prevents large + /// floor/facade tiles from appearing briefly and disappearing again while the + /// camera settles near a streaming boundary. + public var minimumParsedTileResidentSeconds: Double = 8.0 + /// Total CPU memory (MB) allowed to be in-flight across all concurrent tile parses. /// Small tiles consume little budget and can parse in parallel; a single large tile /// may saturate the budget and serialize naturally. @@ -209,6 +228,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { var lastCameraPosition: simd_float3? var cameraVelocity: simd_float3 = .zero + var lastCameraForward: simd_float3 = .init(0, 0, -1) + var lastViewProjMatrix: simd_float4x4 = matrix_identity_float4x4 // MARK: - Frustum Gate @@ -237,13 +258,60 @@ public class GeometryStreamingSystem: @unchecked Sendable { // MARK: - Load Priority - /// When true, load candidates within the same priority tier are sorted by - /// screen-space importance (bounding radius / distance) instead of raw distance. - /// Objects that subtend a larger solid angle in the camera view are loaded first, - /// so a large building at 50 m beats a small prop at 45 m. + /// When true, tile load candidates within the same priority tier are sorted by + /// view-importance score (projected solid angle Γ— view alignment) instead of + /// raw distance. A large tile that fills the center of view loads before a + /// small tile at the same distance on the periphery. /// Default: true. Set to false to revert to pure distance ordering. public var enableImportanceSort: Bool = true + /// Minimum view-alignment weight for tiles at the frustum edge. + /// Remaps the dot-product alignment from [0, 1] to [minWeight, 1.0] so a + /// peripheral tile still contributes its solid angle rather than scoring zero. + /// Range [0, 1]. Default 0.2. + public var viewAlignmentMinWeight: Float = 0.2 + + /// When true, tile load candidates are additionally weighted by how much + /// of their screen-space footprint is already covered by closer loaded tiles. + /// A tile 100% covered by a loaded occluder scores 0 and is deprioritised + /// without being excluded β€” it will still load once the slot is free. + /// Default: true. Set to false to disable occlusion weighting. + public var enableOcclusionSort: Bool = true + + /// Coverage fraction at which a tile is treated as fully occluded and + /// receives occlusionScore = occlusionMinWeight. A tile 85% covered by + /// loaded geometry is unlikely to contribute meaningfully to the visible scene. + /// Range (0, 1]. Default 0.85. + public var occlusionFullThreshold: Float = 0.85 + + /// Minimum occlusionScore returned even for tiles classified as fully blocked. + /// Tile AABBs are used as opaque proxies, but the actual geometry may be sparse, + /// glass, or have open areas. A non-zero floor ensures no tile is permanently + /// pushed to the back of the load queue solely because another tile's bounding + /// box overlaps it in screen space β€” it will still load, just later. + /// Range [0, 1). Default 0.05. Set to 0 to restore hard zero behaviour. + public var occlusionMinWeight: Float = 0.05 + + // 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 ScreenRect { + var minX: Float + var minY: Float + var maxX: Float + var maxY: Float + + var area: Float { + max(0, maxX - minX) * max(0, maxY - minY) + } + + func intersectionArea(with other: ScreenRect) -> Float { + let ix = max(0, min(maxX, other.maxX) - max(minX, other.minX)) + let iy = max(0, min(maxY, other.maxY) - max(minY, other.minY)) + return ix * iy + } + } + // MARK: - OS Memory Pressure /// Set by the MemoryBudgetManager pressure callback (background queue). @@ -255,6 +323,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { let stateLock = NSLock() var timeSinceLastUpdate: Float = 0 var timeSinceCameraDiagLog: Float = 0 + var lastCameraWallDiagTime: Double = 0.0 + var sessionStartWallTime: Double = 0.0 + /// Peak streaming tick duration (ms) since the last heartbeat β€” reset each heartbeat. + var peakTickMs: Double = 0.0 var activeLoads: Set = [] var activeLoadStartTimes: [EntityID: Double] = [:] /// Subset of activeLoads that belong to the near band. Tracked separately so the @@ -394,6 +466,23 @@ public class GeometryStreamingSystem: @unchecked Sendable { withStateLock { _ = loadedTileEntities.insert(entityId) } } + func tileUnloadDwellSatisfied(_ tileComp: TileComponent, now: CFAbsoluteTime) -> Bool { + guard tileComp.pendingUnloadSince > 0, + now - tileComp.pendingUnloadSince >= Double(unloadGracePeriod) + else { + return false + } + + guard tileComp.state == .parsed, + tileComp.parsedResidentSince > 0, + minimumParsedTileResidentSeconds > 0 + else { + return true + } + + return now - tileComp.parsedResidentSince >= minimumParsedTileResidentSeconds + } + func unmarkLoadedTileEntity(_ entityId: EntityID) { withStateLock { _ = loadedTileEntities.remove(entityId) } } @@ -516,6 +605,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { } timeSinceLastUpdate = 0 let updateStart = CFAbsoluteTimeGetCurrent() + if sessionStartWallTime == 0.0 { + sessionStartWallTime = updateStart + lastCameraWallDiagTime = updateStart + } // Use Octree for efficient spatial query - only check nearby entities for loading // Query with the max unload radius to catch all potentially relevant entities @@ -542,14 +635,19 @@ public class GeometryStreamingSystem: @unchecked Sendable { ? effectiveCameraPosition + cameraVelocity * velocityLookAheadTime : effectiveCameraPosition - // Periodic camera position log β€” confirms the XR headset position is flowing through - // to the streaming system. Fires every 5 s so it is readable in a test session without - // being noisy in steady-state. Check these values are changing when physically walking - // on Vision Pro; a frozen value indicates the ARKitβ†’ECS sync is broken. - timeSinceCameraDiagLog += deltaTime - if timeSinceCameraDiagLog >= 5.0 { - timeSinceCameraDiagLog = 0 - Logger.log(message: "[GeometryStreaming] camera pos: (\(String(format: "%.2f", effectiveCameraPosition.x)), \(String(format: "%.2f", effectiveCameraPosition.y)), \(String(format: "%.2f", effectiveCameraPosition.z))) loaded=\(loadedStreamingEntities.count)", category: LogCategory.xrCamera.rawValue) + // Periodic heartbeat β€” uses wall-clock time so it fires every 5 s of real time + // regardless of deltaTime magnitude or tick throttling. + let wallNow = CFAbsoluteTimeGetCurrent() + if wallNow - lastCameraWallDiagTime >= 5.0 { + lastCameraWallDiagTime = wallNow + let bStats = MemoryBudgetManager.shared.getStats() + let bPct = Int((bStats.geometryUtilization * 100).rounded()) + let tilesLoaded = loadedTileEntitiesSnapshot().count + let tilesLoading = loadingTileEntitiesSnapshot().count + 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)") } let nearbyEntities = OctreeSystem.shared.queryNear(point: effectiveCameraPosition, radius: maxQueryRadius) @@ -561,6 +659,23 @@ public class GeometryStreamingSystem: @unchecked Sendable { // coarser than mesh stubs β€” a single tile pop-in is far more noticeable. let tileStreamingFrustum: Frustum? = enableFrustumGate ? buildStreamingFrustum(sidePad: tileFrustumGatePadding) : nil + // Extract camera forward and view-projection matrix for tile importance scoring. + // viewProjMatrixValid is tick-local: it resets to false every update so a + // missing or unavailable camera cannot leave a stale VP matrix active. + // Occlusion scoring is disabled for the tick when the flag is false. + var viewProjMatrixValid = false + if let cameraId = CameraSystem.shared.activeCamera, + let cc = scene.get(component: CameraComponent.self, for: cameraId) + { + let ev = SceneRootTransform.shared.effectiveViewMatrix(cc.viewSpace) + // The third row of the view matrix is -cameraForward in right-handed view space. + let fwd = simd_float3(-ev.columns.0.z, -ev.columns.1.z, -ev.columns.2.z) + let len = simd_length(fwd) + if len > 1e-6 { lastCameraForward = fwd / len } + lastViewProjMatrix = simd_mul(renderInfo.perspectiveSpace, ev) + viewProjMatrixValid = true + } + var loadCandidates: [(EntityID, Float, Int, Float)] = [] // (entity, distance, priority, importance) var unloadCandidates: [(EntityID, Float)] = [] // (entity, distance) @@ -709,7 +824,33 @@ public class GeometryStreamingSystem: @unchecked Sendable { // policy). Concurrency is governed by a memory budget gate (4.4) instead // of a hard count: small tiles parse in parallel; one large tile saturates // the budget naturally. - var tileLoadCandidates: [(EntityID, Float, Int, Float)] = [] // (entity, effectiveDist, priority, importance) + + // Build the occluder list once per tick from already-loaded tiles. + // Manifest-stored bounds (LocalTransformComponent) are available for all + // tiles regardless of load state, so only the .parsed filter is needed β€” + // no chicken-and-egg problem. Sorted ascending by distance so the coverage + // accumulation loop can exit early once it passes the candidate's distance. + var tileOccluders: [TileOccluder] = [] + if enableOcclusionSort, viewProjMatrixValid { + for eid in loadedTileEntitiesSnapshot() { + guard scene.exists(eid), + let tc = scene.get(component: TileComponent.self, for: eid), + tc.state == .parsed, + let local = scene.get(component: LocalTransformComponent.self, for: eid) + else { continue } + let dist = calculateDistance(entityId: eid, cameraPosition: effectiveCameraPosition) + let rect = projectAABBToScreen( + min: local.boundingBox.min, max: local.boundingBox.max, + viewProj: lastViewProjMatrix, + allowNearPlaneExpansion: false // discard tiles clipping the near plane + ) + guard rect.area > 1e-6 else { continue } // behind or clipping camera β€” not a valid occluder + tileOccluders.append(TileOccluder(rect: rect, distance: dist)) + } + tileOccluders.sort { $0.distance < $1.distance } + } + + var tileLoadCandidates: [(EntityID, Float, Int, Float, Float, Float)] = [] // (entity, effectiveDist, priority, solidAngle, viewAlignment, occlusionScore) for entityId in nearbyEntities { guard scene.exists(entityId) else { continue } guard let tileComp = scene.get(component: TileComponent.self, for: entityId) @@ -727,6 +868,20 @@ public class GeometryStreamingSystem: @unchecked Sendable { guard tileComp.state == .unloaded else { continue } + // Floor-proximity gate: skip tile stubs whose Y centre is too far from the + // camera. Without this, all 10 floors are simultaneous load candidates + // inside a multi-floor building β€” O(floor_count Γ— tiles_per_floor) work + // per tick that spikes when the camera crosses a floor boundary. + // Tiles already PARSED are not affected (unload is governed by their own + // unloadRadius); the gate only suppresses new load dispatches for distant floors. + if tileComp.isInterior, + floorProximityGateY < Float.greatestFiniteMagnitude, + tileComp.hasFloorMetadata + { + let yDist = abs(tileComp.worldYCenter - effectiveCameraPosition.y) + if yDist > floorProximityGateY { continue } + } + let distance = calculateDistance(entityId: entityId, cameraPosition: effectiveCameraPosition) // 4.5: Use predictive distance (min of actual vs look-ahead) so tiles in @@ -765,7 +920,30 @@ public class GeometryStreamingSystem: @unchecked Sendable { continue } } - tileLoadCandidates.append((entityId, effectiveDist, tileComp.priority, importanceScore(entityId: entityId, distance: effectiveDist))) + let (sa, va) = tileImportanceComponents( + entityId: entityId, + distance: effectiveDist, + cameraPosition: effectiveCameraPosition, + cameraForward: lastCameraForward + ) + // Occlusion score: fraction of this tile's screen footprint NOT + // covered by closer loaded tiles. 1.0 = fully visible, 0 = fully + // blocked. Skipped when occluder list is empty (no loaded tiles yet) + // or when occlusion sort is disabled. + let occ: 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 + ) + occ = tileOcclusionScore(candidateRect: rect, distance: effectiveDist, + occluders: tileOccluders) + } else { + occ = 1.0 + } + tileLoadCandidates.append((entityId, effectiveDist, tileComp.priority, sa, va, occ)) } } if !tileLoadCandidates.isEmpty { @@ -777,15 +955,33 @@ public class GeometryStreamingSystem: @unchecked Sendable { TextureStreamingSystem.shared.shedTextureMemory( cameraPosition: effectiveCameraPosition, maxEntities: 4 ) - _ = evictLRU(cameraPosition: effectiveCameraPosition, maxEvictions: 8) + let lruEvicted = evictLRU(cameraPosition: effectiveCameraPosition, maxEvictions: 8) + // evictLRU only reclaims OCC/out-of-core stubs. When all loaded geometry is + // from the fullLoad path (0 OCC stubs), lruEvicted is always 0 and the budget + // never clears, permanently blocking the tile load loop. evictTileGeometry + // handles fullLoad tiles, HLODs, and LODs as a second-stage pass. + 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.") + } + } } + // Normalize solid angles relative to the largest candidate so the + // score is scale-invariant across different scene sizes. + let maxSA = tileLoadCandidates.max(by: { $0.3 < $1.3 })?.3 ?? 1.0 + let saFloor = max(maxSA, 1e-6) tileLoadCandidates.sort { lhs, rhs in if lhs.2 != rhs.2 { return lhs.2 > rhs.2 } // priority descending - if enableImportanceSort { return lhs.3 > rhs.3 } // larger screen footprint first + if enableImportanceSort { + let lScore = (lhs.3 / saFloor) * lhs.4 * lhs.5 // solidAngleNorm Γ— viewAlignment Γ— occlusionScore + let rScore = (rhs.3 / saFloor) * rhs.4 * rhs.5 + if abs(lScore - rScore) > 0.001 { return lScore > rScore } + } return lhs.1 < rhs.1 // fallback: closer first } - for (entityId, _, _, _) in tileLoadCandidates { + for (entityId, _, _, _, _, _) in tileLoadCandidates { // Hard cap: never exceed maxConcurrentTileLoads regardless of budget. guard activeTileLoadCount() < maxConcurrentTileLoads else { break } // Re-check overall geometry budget after each dispatch. @@ -963,7 +1159,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Both .parsing and .parsed honour the grace period (see comment above). if tileComp.pendingUnloadSince == 0 { tileComp.pendingUnloadSince = now - } else if now - tileComp.pendingUnloadSince >= Double(unloadGracePeriod) { + } else if tileUnloadDwellSatisfied(tileComp, now: now) { tileUnloadCandidates.append(entityId) } } @@ -987,7 +1183,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { if effectiveUnloadDist2 > tileComp.unloadRadius { if tileComp.pendingUnloadSince == 0 { tileComp.pendingUnloadSince = now - } else if now - tileComp.pendingUnloadSince >= Double(unloadGracePeriod) { + } else if tileUnloadDwellSatisfied(tileComp, now: now) { tileUnloadCandidates.append(entityId) } } else if tileComp.pendingUnloadSince != 0 { @@ -1271,6 +1467,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { } let updateWorkMs = (CFAbsoluteTimeGetCurrent() - updateStart) * 1000.0 + peakTickMs = max(peakTickMs, updateWorkMs) let activeLoadsAtEnd = activeLoadCountSnapshot() withStateLock { diagnostics.updateFrame = currentFrame @@ -1330,6 +1527,186 @@ public class GeometryStreamingSystem: @unchecked Sendable { return radius / max(distance, 1.0) } + /// Returns the two raw importance components for a tile load candidate. + /// + /// - `solidAngle`: projected silhouette area of the tile AABB as seen from the + /// camera, divided by distanceΒ². Proportional to how many pixels the tile + /// occupies. Unnormalized β€” the caller normalizes across the full candidate set. + /// - `viewAlignment`: how centered the tile is in the camera's view, remapped + /// from [0, 1] to [viewAlignmentMinWeight, 1.0] so peripheral tiles are + /// penalised but not zeroed. + /// + /// Tile stubs have identity world transforms so local AABB == world AABB; + /// no matrix multiply is needed to get world-space half-extents. + func tileImportanceComponents( + entityId: EntityID, + distance: Float, + cameraPosition: simd_float3, + cameraForward: simd_float3 + ) -> (solidAngle: Float, viewAlignment: Float) { + guard let local = scene.get(component: LocalTransformComponent.self, for: entityId) + else { return (0, 1) } + + let half = (local.boundingBox.max - local.boundingBox.min) * 0.5 + let center = (local.boundingBox.min + local.boundingBox.max) * 0.5 + + // Unit vector from camera to tile center. + // Falls back to cameraForward when the camera is inside the tile (distance β‰ˆ 0). + let raw = center - cameraPosition + let rawLen = simd_length(raw) + let dir = rawLen > 1e-4 ? raw / rawLen : cameraForward + + // Projected silhouette area of the AABB seen from direction dir: + // 2 Γ— (hyΒ·hzΒ·|dx| + hxΒ·hzΒ·|dy| + hxΒ·hyΒ·|dz|) + // Captures the actual visible footprint of anisotropic tiles (floors, walls) + // that radius/distance treats as spheres and systematically undersizes. + let projectedArea = 2.0 * (half.y * half.z * abs(dir.x) + + half.x * half.z * abs(dir.y) + + half.x * half.y * abs(dir.z)) + let solidAngle = projectedArea / max(distance * distance, 1.0) + + // View alignment: direction from camera to the CLOSEST AABB surface point, + // not the center. For large anisotropic tiles (a 400 m facade, a wide floor + // slab) the center can be far off-axis while the visible surface is directly + // in front β€” using the center underranks these tiles when they matter most. + // The closest point represents "the part the camera is actually pointing toward." + // Falls back to cameraForward when the camera is inside the AABB (distance β‰ˆ 0). + let closestPoint = simd_clamp(cameraPosition, local.boundingBox.min, local.boundingBox.max) + let rawClose = closestPoint - cameraPosition + let closeLen = simd_length(rawClose) + let closeDir = closeLen > 1e-4 ? rawClose / closeLen : cameraForward + let alignment = max(0, simd_dot(cameraForward, closeDir)) + let viewAlignment = viewAlignmentMinWeight + (1.0 - viewAlignmentMinWeight) * alignment + + return (solidAngle, viewAlignment) + } + + /// Projects an AABB into NDC screen space using the cached view-projection matrix. + /// + /// All 8 corners are transformed. Corners with w ≀ 0 (behind the near plane) + /// are skipped; what happens next depends on `allowNearPlaneExpansion`: + /// + /// true (candidates): expands the rect to the screen edges so partially-clipped + /// tiles don't undercount their footprint β€” a tile the camera is near should + /// not be penalised by a smaller-than-real occluder target. + /// + /// false (occluders): returns a zero-area rect immediately. A tile whose AABB + /// clips the near plane β€” e.g. an ExteriorShell the camera is standing inside + /// β€” would otherwise expand to fill the screen and drive every candidate's + /// occlusionScore to 0. + /// + /// If every corner is behind the camera a zero-area rect is returned in both modes. + func projectAABBToScreen(min bbMin: simd_float3, max bbMax: simd_float3, + viewProj: simd_float4x4, + allowNearPlaneExpansion: Bool = true) -> ScreenRect + { + let corners: [simd_float3] = [ + bbMin, + simd_float3(bbMax.x, bbMin.y, bbMin.z), + simd_float3(bbMin.x, bbMax.y, bbMin.z), + simd_float3(bbMin.x, bbMin.y, bbMax.z), + simd_float3(bbMax.x, bbMax.y, bbMin.z), + simd_float3(bbMax.x, bbMin.y, bbMax.z), + simd_float3(bbMin.x, bbMax.y, bbMax.z), + bbMax, + ] + var ndcMinX = Float.greatestFiniteMagnitude + var ndcMinY = Float.greatestFiniteMagnitude + var ndcMaxX: Float = -Float.greatestFiniteMagnitude + var ndcMaxY: Float = -Float.greatestFiniteMagnitude + var anyBehind = false + var hasValid = false + + for c in corners { + let clip = viewProj * simd_float4(c, 1) + guard clip.w > 1e-6 else { anyBehind = true; continue } + hasValid = true + let nx = clip.x / clip.w + let ny = clip.y / clip.w + ndcMinX = min(ndcMinX, nx); ndcMaxX = max(ndcMaxX, nx) + ndcMinY = min(ndcMinY, ny); ndcMaxY = max(ndcMaxY, ny) + } + + guard hasValid else { return ScreenRect(minX: 0, minY: 0, maxX: 0, maxY: 0) } + + if anyBehind { + guard allowNearPlaneExpansion else { + // Occluder mode: discard near-plane-clipped tiles rather than expanding + // them to full-screen, which would falsely drive candidate scores to 0. + return ScreenRect(minX: 0, minY: 0, maxX: 0, maxY: 0) + } + // Candidate mode: expand conservatively so the footprint isn't undercounted. + ndcMinX = min(ndcMinX, -1); ndcMaxX = max(ndcMaxX, 1) + ndcMinY = min(ndcMinY, -1); ndcMaxY = max(ndcMaxY, 1) + } + return ScreenRect(minX: max(-1, ndcMinX), minY: max(-1, ndcMinY), + maxX: min(1, ndcMaxX), maxY: min(1, ndcMaxY)) + } + + /// Maps a ScreenRect to the 8Γ—8 NDC grid cells it overlaps, as a UInt64 bitmask. + /// + /// The screen is divided into 64 cells (8 columns Γ— 8 rows) each 0.25 NDC units wide. + /// Bit i represents cell (i / 8, i % 8). Cell boundaries that fall inside a rect are + /// included conservatively (floor of the upper bound) so occluder coverage is never + /// understated. Using a bitmask means unioning two overlapping occluders with `|` + /// produces the correct union area β€” no double-counting. + func rectToScreenMask(_ rect: ScreenRect) -> UInt64 { + // A zero-area rect (e.g. from an all-behind-camera tile) maps min == max to the + // same cell on each axis, passing the c0 <= c1 guard and producing a spurious + // single-cell mask. Reject before any cell computation. + guard rect.area > 1e-6 else { return 0 } + let gridN = 8 + let scale = Float(gridN) * 0.5 // maps NDC [-1, 1] β†’ [0, gridN] + let c0 = max(0, Int(floor((rect.minX + 1.0) * scale))) + let c1 = min(gridN - 1, Int(floor((rect.maxX + 1.0) * scale))) + let r0 = max(0, Int(floor((rect.minY + 1.0) * scale))) + let r1 = min(gridN - 1, Int(floor((rect.maxY + 1.0) * scale))) + guard c0 <= c1, r0 <= r1 else { return 0 } + var mask: UInt64 = 0 + for r in r0 ... r1 { + for c in c0 ... c1 { + mask |= 1 << UInt64(r * gridN + c) + } + } + return mask + } + + /// Returns the fraction of `candidateRect` NOT covered by the union of closer + /// loaded-tile occluders. 1.0 = fully visible, occlusionMinWeight = fully blocked. + /// + /// Coverage is computed on an 8Γ—8 NDC grid using bitmask union (|) so overlapping + /// occluders are never double-counted. Occluders must be sorted ascending by + /// distance for the early-exit to work. + /// + /// The return value is floored at occlusionMinWeight (default 0.05) rather than 0 + /// so a tile classified as fully blocked by AABB heuristics still has a small chance + /// of loading β€” tile AABBs are opaque proxies and may over-occlude glass, sparse + /// meshes, or concave geometry. + func tileOcclusionScore(candidateRect: ScreenRect, distance: Float, + occluders: [TileOccluder]) -> Float + { + let candidateMask = rectToScreenMask(candidateRect) + guard candidateMask != 0 else { return 1.0 } + let candidateCells = candidateMask.nonzeroBitCount + + // Convert the threshold fraction to a cell count once; avoids repeated division. + let thresholdCells = Int((Float(candidateCells) * occlusionFullThreshold).rounded(.up)) + + var unionMask: UInt64 = 0 + for occluder in occluders { + guard occluder.distance < distance else { break } + unionMask |= rectToScreenMask(occluder.rect) + // Early exit when enough cells are covered β€” floor at occlusionMinWeight so + // over-conservative AABB coverage (glass, sparse mesh) doesn't hard-block loads. + if (unionMask & candidateMask).nonzeroBitCount >= thresholdCells { + return occlusionMinWeight + } + } + + let coveredCells = (unionMask & candidateMask).nonzeroBitCount + return max(occlusionMinWeight, 1.0 - Float(coveredCells) / Float(candidateCells)) + } + 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) @@ -1509,6 +1886,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { tc.loadTask = nil if tc.state == .parsing { tc.state = .unloaded } tc.pendingUnloadSince = 0 + tc.parsedResidentSince = 0 // Cancel any in-flight HLOD load and reset state. tc.hlodLoadTask?.cancel() tc.hlodLoadTask = nil diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index ab8db3e8..c02041bb 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -1036,19 +1036,12 @@ public func setEntityMeshAsync( await AssetLoadingState.shared.finishLoading(entityId: entityId) } - // Ensure entity has required components while loading gate is active. - if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: entityId) - } - - if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: entityId) - } - // Get URL guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { handleError(.filenameNotFound, filename) - loadFallbackMesh(entityId: entityId, filename: filename) + withWorldMutationGate { + loadFallbackMesh(entityId: entityId, filename: filename) + } await AssetLoadingState.shared.finishLoading(entityId: entityId) completionBox?.call(false) return @@ -1056,7 +1049,9 @@ public func setEntityMeshAsync( if url.pathExtension == "dae" { handleError(.fileTypeNotSupported, url.pathExtension) - loadFallbackMesh(entityId: entityId, filename: filename) + withWorldMutationGate { + loadFallbackMesh(entityId: entityId, filename: filename) + } await AssetLoadingState.shared.finishLoading(entityId: entityId) completionBox?.call(false) return @@ -1064,7 +1059,9 @@ public func setEntityMeshAsync( if RuntimeAssetSource.infer(from: url).kind == .untold { guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { - loadFallbackMesh(entityId: entityId, filename: filename) + withWorldMutationGate { + loadFallbackMesh(entityId: entityId, filename: filename) + } await AssetLoadingState.shared.finishLoading(entityId: entityId) completionBox?.call(false) return @@ -1093,29 +1090,41 @@ public func setEntityMeshAsync( } } - let didLoad: Bool - if useOCC { - didLoad = registerUntoldRuntimeAssetOCC( - entityId: entityId, - runtimeAsset: runtimeAsset, - url: url, - filename: filename, - withExtension: withExtension, - assetName: assetName - ) - } else { - didLoad = registerUntoldRuntimeAsset( - entityId: entityId, - runtimeAsset: runtimeAsset, - url: url, - filename: filename, - withExtension: withExtension, - assetName: assetName - ) - } + let didLoad: Bool = withWorldMutationGate { + if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { + registerTransformComponent(entityId: entityId) + } - if !didLoad { - loadFallbackMesh(entityId: entityId, filename: filename) + if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { + registerSceneGraphComponent(entityId: entityId) + } + + let loaded: Bool + if useOCC { + loaded = registerUntoldRuntimeAssetOCC( + entityId: entityId, + runtimeAsset: runtimeAsset, + url: url, + filename: filename, + withExtension: withExtension, + assetName: assetName + ) + } else { + loaded = registerUntoldRuntimeAsset( + entityId: entityId, + runtimeAsset: runtimeAsset, + url: url, + filename: filename, + withExtension: withExtension, + assetName: assetName + ) + } + + if !loaded { + loadFallbackMesh(entityId: entityId, filename: filename) + } + + return loaded } await AssetLoadingState.shared.finishLoading(entityId: entityId) @@ -1125,7 +1134,9 @@ public func setEntityMeshAsync( // Non-.untold assets are not supported. Log and return a fallback. Logger.logWarning(message: "[RegistrationSystem] Only .untold format is supported. Ignoring '\(filename).\(withExtension)'.") - loadFallbackMesh(entityId: entityId, filename: filename) + withWorldMutationGate { + loadFallbackMesh(entityId: entityId, filename: filename) + } await AssetLoadingState.shared.finishLoading(entityId: entityId) completionBox?.call(false) } @@ -1643,7 +1654,13 @@ private func registerTiledScene( } else { manifestTileSize = tileManifest.streamingDefaults.streamingRadius } - BatchingSystem.shared.setBatchCellSize(manifestTileSize * 2.0) + // Cell size = 1Γ— tile footprint (was 2Γ—). At 2Γ— (50 m cells) each cell contains + // ~46 tiles Γ— ~3 entities Γ— ~2500 vertices β‰ˆ 350 K vertices β€” more than 2Γ— the + // 160 K complexity-guard limit, so 6 of 9 cells are permanently blocked from batching + // and render individually (opaque 300 draw calls β†’ GPU overload β†’ freeze). + // At 1Γ— (25 m cells) each cell contains ~15 tiles Γ— ~3 entities Γ— ~2500 vertices + // β‰ˆ 115 K vertices, within the limit β€” all cells form batch groups. + BatchingSystem.shared.setBatchCellSize(manifestTileSize * 1.0) enableBatching(true) // ── 4. Register tile stub entities ──────────────────────────────────── @@ -1722,6 +1739,11 @@ private func registerTiledScene( tileComp.prefetchRadius = normalizedBands.prefetchRadius tileComp.tileId = tile.tileId tileComp.isInterior = tile.isInterior ?? false + tileComp.hasFloorMetadata = tileManifest.partitioningMode == "quadtree_floor" && tile.floorId != nil + tileComp.floorId = tile.floorId ?? 0 + tileComp.worldYCenter = tile.center.count >= 2 + ? Float(tile.center[1]) // manifest center[1] = Y (engine up-axis) + : 0 tileComp.state = .unloaded // Log semantic-tier info when present (v4 quadtree manifests). diff --git a/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift b/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift index fcd4ea8d..22bf14e5 100644 --- a/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift +++ b/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift @@ -244,17 +244,58 @@ final class RemoteStreamFlyThroughTests: BaseRenderSetup { } } - /// True when at least one tile has parsed and no tile is still mid-parse. + /// True when at least one tile has parsed and every tile currently inside + /// the prefetch band has either parsed or moved out of consideration. private func tilesAreReady(sceneRoot: EntityID) -> Bool { + let camera = findGameCamera() + let cameraPosition = getCameraPosition(entityId: camera) + let tileFrustum = GeometryStreamingSystem.shared.enableFrustumGate + ? GeometryStreamingSystem.shared.buildStreamingFrustum( + sidePad: GeometryStreamingSystem.shared.tileFrustumGatePadding + ) + : nil let tileIds = getEntityChildren(parentId: sceneRoot) guard !tileIds.isEmpty else { return false } let hasParsed = tileIds.contains { scene.get(component: TileComponent.self, for: $0)?.state == .parsed } - let stillParsing = tileIds.contains { - scene.get(component: TileComponent.self, for: $0)?.state == .parsing + let hasUnreadyRelevantTile = tileIds.contains { + guard let tile = scene.get(component: TileComponent.self, for: $0) else { return false } + guard tile.state != .parsed else { return false } + guard tilePassesFloorGate(tile: tile, cameraPosition: cameraPosition) else { return false } + guard tilePassesFrustumGate(entityId: $0, frustum: tileFrustum) else { return false } + return tileDistance(entityId: $0, cameraPosition: cameraPosition) <= tile.effectivePrefetchRadius + 1.0 + } + return hasParsed && !hasUnreadyRelevantTile + } + + private func tilePassesFloorGate(tile: TileComponent, cameraPosition: simd_float3) -> Bool { + let gate = GeometryStreamingSystem.shared.floorProximityGateY + guard gate < Float.greatestFiniteMagnitude, tile.hasFloorMetadata else { return true } + return abs(tile.worldYCenter - cameraPosition.y) <= gate + } + + private func tilePassesFrustumGate(entityId: EntityID, frustum: Frustum?) -> Bool { + guard let frustum else { return true } + guard 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: frustum) + } + + private func tileDistance(entityId: EntityID, cameraPosition: simd_float3) -> Float { + guard let local = scene.get(component: LocalTransformComponent.self, for: entityId) else { + return Float.greatestFiniteMagnitude } - return hasParsed && !stillParsing + + let closest = simd_float3( + min(max(cameraPosition.x, local.boundingBox.min.x), local.boundingBox.max.x), + min(max(cameraPosition.y, local.boundingBox.min.y), local.boundingBox.max.y), + min(max(cameraPosition.z, local.boundingBox.min.z), local.boundingBox.max.z) + ) + return simd_length(cameraPosition - closest) } /// Simulates the camera flying from `origin` to `destination` at 60 fps diff --git a/Tests/UntoldEngineRenderTests/Resources/compare_psnr.py b/Tests/UntoldEngineRenderTests/Resources/compare_psnr.py index 8bd61466..089a58ec 100644 --- a/Tests/UntoldEngineRenderTests/Resources/compare_psnr.py +++ b/Tests/UntoldEngineRenderTests/Resources/compare_psnr.py @@ -78,6 +78,12 @@ def to_rgb(img: np.ndarray): return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) return img +def psnr_data_range(*imgs: np.ndarray): + dtype = np.result_type(*[img.dtype for img in imgs]) + if np.issubdtype(dtype, np.integer): + return np.iinfo(dtype).max + return 1.0 + def main(): ref_path, test_path, threshold, resize, mode = parse_args(sys.argv) @@ -127,8 +133,7 @@ def main(): ref_use = to_rgb(ref) test_use = to_rgb(test) - # Compute PSNR on uint8 directly (data_range=255) - psnr = peak_signal_noise_ratio(ref_use, test_use, data_range=255) + psnr = peak_signal_noise_ratio(ref_use, test_use, data_range=psnr_data_range(ref_use, test_use)) print(f"{psnr:.6f}") if psnr < threshold: @@ -137,4 +142,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/Tests/UntoldEngineRenderTests/StreamingGateTests.swift b/Tests/UntoldEngineRenderTests/StreamingGateTests.swift index 57b13d97..ba4f181e 100644 --- a/Tests/UntoldEngineRenderTests/StreamingGateTests.swift +++ b/Tests/UntoldEngineRenderTests/StreamingGateTests.swift @@ -106,7 +106,10 @@ final class StreamingGateTests: BaseRenderSetup { center: simd_float3, halfExtent: simd_float3 = simd_float3(1, 1, 1), streamingRadius: Float, - unloadRadius: Float + unloadRadius: Float, + hasFloorMetadata: Bool = false, + worldYCenter: Float? = nil, + isInterior: Bool = true ) -> EntityID { let entityId = createEntity() registerTransformComponent(entityId: entityId) @@ -124,6 +127,9 @@ final class StreamingGateTests: BaseRenderSetup { tc.tileURL = URL(fileURLWithPath: "/dev/null") // parse fails fast; .parsing is set synchronously tc.streamingRadius = streamingRadius tc.unloadRadius = unloadRadius + tc.hasFloorMetadata = hasFloorMetadata + tc.worldYCenter = worldYCenter ?? center.y + tc.isInterior = isInterior tc.state = .unloaded } @@ -264,6 +270,294 @@ final class StreamingGateTests: BaseRenderSetup { XCTAssertEqual(tcOut.state, .parsing, "Out-of-frustum tile must also dispatch when gate is disabled") } + // MARK: - Floor proximity gate + + func testFloorProximityGate_ignoresLargeYCenterWithoutFloorMetadata() throws { + let tile = makeTileStub( + center: .zero, + streamingRadius: 1000.0, + unloadRadius: 2000.0, + hasFloorMetadata: false, + worldYCenter: 5000.0 + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tc = try XCTUnwrap(scene.get(component: TileComponent.self, for: tile)) + XCTAssertEqual(tc.state, .parsing, + "Uniform-grid tiles must not be floor-gated just because their manifest Y center is nonzero") + } + + func testFloorProximityGate_blocksVerticallyDistantFloorTile() throws { + let tile = makeTileStub( + center: .zero, + streamingRadius: 1000.0, + unloadRadius: 2000.0, + hasFloorMetadata: true, + worldYCenter: 5000.0 + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tc = try XCTUnwrap(scene.get(component: TileComponent.self, for: tile)) + XCTAssertEqual(tc.state, .unloaded, + "Floor-aware tiles outside floorProximityGateY must remain unloaded") + } + + func testFloorProximityGate_doesNotBlockExteriorShellTile() throws { + let tile = makeTileStub( + center: .zero, + streamingRadius: 1000.0, + unloadRadius: 2000.0, + hasFloorMetadata: true, + worldYCenter: 5000.0, + isInterior: false + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tc = try XCTUnwrap(scene.get(component: TileComponent.self, for: tile)) + XCTAssertEqual(tc.state, .parsing, + "Exterior shell tiles must not be floor-gated; upper facade floors need to stream from outside") + } + + // MARK: - Importance sort + + // These tests verify that tileImportanceComponents drives the tile load order. + // maxConcurrentTileLoads is forced to 1 so only the top-ranked candidate is + // dispatched per tick β€” the second tile remaining .unloaded is the observable + // signal that ordering worked correctly. + // + // Geometry notes (camera at origin, cameraForward = (0, 0, -1)): + // Surface distance = simd_distance(camera, closestAABBPoint) + // Solid angle = projectedSilhouetteArea / distanceΒ² + // ViewAlignment = viewAlignmentMinWeight + (1 βˆ’ minWeight) Γ— dot(forward, dirToTile) + + func testImportanceSort_largeAabbLoadsBeforeSmallAtEqualDistance() throws { + setUpCameraLookingNegativeZ() + let oldMax = GeometryStreamingSystem.shared.maxConcurrentTileLoads + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 1 + defer { GeometryStreamingSystem.shared.maxConcurrentTileLoads = oldMax } + + // Both tiles have AABB surface-distance 50 m so raw distance cannot break the + // tie. The large tile (hx=hy=hz=5) subtends a 25Γ— greater solid angle + // (projectedArea = 50 vs 2) and must be dispatched first. + let largeTile = makeTileStub( + center: simd_float3(0, 0, -55), + halfExtent: simd_float3(5, 5, 5), // AABB max-z = -50 β†’ surface dist = 50 m + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + let smallTile = makeTileStub( + center: simd_float3(0, 0, -51), + halfExtent: simd_float3(1, 1, 1), // AABB max-z = -50 β†’ surface dist = 50 m + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tcLarge = try XCTUnwrap(scene.get(component: TileComponent.self, for: largeTile)) + let tcSmall = try XCTUnwrap(scene.get(component: TileComponent.self, for: smallTile)) + XCTAssertEqual(tcLarge.state, .parsing, + "Large tile (solid angle 25Γ— greater) must be dispatched first at equal surface distance") + XCTAssertEqual(tcSmall.state, .unloaded, + "Small tile must wait when the single load slot is held by the larger-solid-angle tile") + } + + func testImportanceSort_onAxisTileLoadsBeforePeripheralAtEqualAabbAndDistance() throws { + setUpCameraLookingNegativeZ() + // Frustum gate is already disabled in setUp; the 90Β°-off-axis tile must reach + // the importance sort, not be filtered before it. + let oldMax = GeometryStreamingSystem.shared.maxConcurrentTileLoads + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 1 + defer { GeometryStreamingSystem.shared.maxConcurrentTileLoads = oldMax } + + // Both cubes: halfExtent (1,1,1), surface distance 50 m. + // Equal solid angles β€” view alignment is the only differentiator. + // On-axis: dot((0,0,-1),(0,0,-1)) = 1.0 β†’ viewAlignment = 1.0 + // Off-axis: dot((0,0,-1),(1,0,0)) = 0.0 β†’ viewAlignment = 0.2 (minWeight) + let onAxisTile = makeTileStub( + center: simd_float3(0, 0, -51), // directly ahead; surface at z = -50 + halfExtent: simd_float3(1, 1, 1), + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + let offAxisTile = makeTileStub( + center: simd_float3(51, 0, 0), // 90Β° off-axis; surface at x = 50 + halfExtent: simd_float3(1, 1, 1), + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tcOn = try XCTUnwrap(scene.get(component: TileComponent.self, for: onAxisTile)) + let tcOff = try XCTUnwrap(scene.get(component: TileComponent.self, for: offAxisTile)) + XCTAssertEqual(tcOn.state, .parsing, + "On-axis tile (viewAlignment 1.0) must load before peripheral tile (viewAlignment 0.2)") + XCTAssertEqual(tcOff.state, .unloaded, + "Peripheral tile must wait when the load slot is held by the on-axis tile") + } + + func testImportanceSort_disabledRevertsToPureDistanceOrdering() throws { + setUpCameraLookingNegativeZ() + let oldMax = GeometryStreamingSystem.shared.maxConcurrentTileLoads + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 1 + GeometryStreamingSystem.shared.enableImportanceSort = false + defer { + GeometryStreamingSystem.shared.maxConcurrentTileLoads = oldMax + GeometryStreamingSystem.shared.enableImportanceSort = true + } + + // Close small tile at 30 m; far large tile at 60 m. + // With importance sort OFF the streaming system falls back to raw distance: + // the close tile wins despite having a ~400Γ— smaller solid angle. + let closeSmallTile = makeTileStub( + center: simd_float3(0, 0, -31), + halfExtent: simd_float3(1, 1, 1), // surface at z = -30 β†’ distance 30 m + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + let farLargeTile = makeTileStub( + center: simd_float3(0, 0, -100), + halfExtent: simd_float3(40, 40, 40), // surface at z = -60 β†’ distance 60 m + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tcClose = try XCTUnwrap(scene.get(component: TileComponent.self, for: closeSmallTile)) + let tcFar = try XCTUnwrap(scene.get(component: TileComponent.self, for: farLargeTile)) + XCTAssertEqual(tcClose.state, .parsing, + "Closer tile must load first when importance sort is disabled") + XCTAssertEqual(tcFar.state, .unloaded, + "Far tile must wait when sort is disabled, regardless of its solid angle advantage") + } + + func testImportanceSort_enabledPrefersHighSolidAngleOverRawDistance() throws { + setUpCameraLookingNegativeZ() + let oldMax = GeometryStreamingSystem.shared.maxConcurrentTileLoads + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 1 + defer { GeometryStreamingSystem.shared.maxConcurrentTileLoads = oldMax } + + // Same geometry as the disabled test; importance sort is on (default). + // The far large tile's solid angle (β‰ˆ0.89) dominates the close small tile's + // (β‰ˆ0.002) by ~400Γ—. It must load first despite being 2Γ— farther away. + let closeSmallTile = makeTileStub( + center: simd_float3(0, 0, -31), + halfExtent: simd_float3(1, 1, 1), // distance 30 m, solidAngle β‰ˆ 0.002 + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + let farLargeTile = makeTileStub( + center: simd_float3(0, 0, -100), + halfExtent: simd_float3(40, 40, 40), // distance 60 m, solidAngle β‰ˆ 0.89 + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tcClose = try XCTUnwrap(scene.get(component: TileComponent.self, for: closeSmallTile)) + let tcFar = try XCTUnwrap(scene.get(component: TileComponent.self, for: farLargeTile)) + XCTAssertEqual(tcFar.state, .parsing, + "Far large tile (solid angle β‰ˆ 0.89) must load first β€” importance beats raw distance") + XCTAssertEqual(tcClose.state, .unloaded, + "Close small tile (solid angle β‰ˆ 0.002) must wait when outranked by solid angle") + } + + // MARK: - Occlusion sort camera guard + + func testOcclusionSort_disabledWhenNoCameraAvailable() throws { + // No active camera β€” viewProjMatrixValid must be false this tick, so the + // occluder build is skipped and both tiles get occlusionScore = 1.0. + // Phase 1 importance sort still applies: the larger tile loads first. + CameraSystem.shared.activeCamera = nil + let oldMax = GeometryStreamingSystem.shared.maxConcurrentTileLoads + GeometryStreamingSystem.shared.maxConcurrentTileLoads = 1 + defer { GeometryStreamingSystem.shared.maxConcurrentTileLoads = oldMax } + + // Large tile and small tile at equal distance β€” large wins on solid angle alone. + let largeTile = makeTileStub( + center: simd_float3(0, 0, -55), + halfExtent: simd_float3(5, 5, 5), + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + let smallTile = makeTileStub( + center: simd_float3(0, 0, -51), + halfExtent: simd_float3(1, 1, 1), + streamingRadius: 200.0, + unloadRadius: 400.0 + ) + + // Should not crash or stall β€” occlusion is simply skipped this tick. + GeometryStreamingSystem.shared.update(cameraPosition: .zero, deltaTime: 0.016) + + let tcLarge = try XCTUnwrap(scene.get(component: TileComponent.self, for: largeTile)) + let tcSmall = try XCTUnwrap(scene.get(component: TileComponent.self, for: smallTile)) + XCTAssertEqual(tcLarge.state, .parsing, + "Large tile must still load first via solid-angle sort even when occlusion is skipped (no camera)") + XCTAssertEqual(tcSmall.state, .unloaded, + "Small tile must wait β€” importance sort still applies without occlusion") + } + + // MARK: - View alignment closest-point fix + + func testViewAlignment_closestAABBPointUsedForLargeTile() { + // A large wall tile whose AABB center is far off to the right, but whose + // AABB surface is directly in front of the camera. + // + // center = (150, 0, -10), halfExtent = (200, 10, 5) + // AABB: x ∈ [-50, 350], y ∈ [-10, 10], z ∈ [-15, -5] + // Camera at origin, forward = (0, 0, -1). + // + // Center direction β‰ˆ normalize(150, 0, -10) β†’ dot β‰ˆ 0.07 (nearly perpendicular) + // Closest AABB point = clamp((0,0,0), min, max) = (0, 0, -5) + // Closest direction = (0, 0, -1) β†’ dot = 1.0 (directly ahead) + // + // With center-based alignment the tile would score β‰ˆ viewAlignmentMinWeight + tiny. + // With closest-point alignment it must score close to 1.0. + GeometryStreamingSystem.shared.lastCameraForward = simd_float3(0, 0, -1) + + let tile = makeTileStub( + center: simd_float3(150, 0, -10), + halfExtent: simd_float3(200, 10, 5), + streamingRadius: 1000.0, + unloadRadius: 2000.0 + ) + + let sys = GeometryStreamingSystem.shared + let (_, va) = sys.tileImportanceComponents( + entityId: tile, + distance: 5.0, // distance from camera to closest AABB point (z = -5) + cameraPosition: .zero, + cameraForward: simd_float3(0, 0, -1) + ) + + XCTAssertGreaterThan(va, 0.9, + "Large wall tile with AABB surface directly ahead must score high view alignment even when its center is far off-axis") + } + + func testViewAlignment_centerBasedWouldUnderrankThisTile() { + // Complementary to the above: verify that the center direction alone would + // produce a near-minimum alignment score for the same tile, confirming that + // the fix is actually doing something. + // center direction β‰ˆ (0.998, 0, -0.067) β†’ dot with (0,0,-1) β‰ˆ 0.067 + // β†’ center-based alignment β‰ˆ 0.2 + 0.8 Γ— 0.067 β‰ˆ 0.253 + let center = simd_float3(150, 0, -10) + let cameraForward = simd_float3(0, 0, -1) + let dirToCenter = simd_normalize(center - .zero) + let centerDot = max(0, simd_dot(cameraForward, dirToCenter)) + let minW = GeometryStreamingSystem.shared.viewAlignmentMinWeight + let centerBasedAlignment = minW + (1.0 - minW) * centerDot + + XCTAssertLessThan(centerBasedAlignment, 0.35, + "Center-based alignment for a far-off-axis center must be low, confirming the fix improves the score") + } + // MARK: - Velocity predictor // Tile position and radii used for all velocity predictor tests. diff --git a/Tests/UntoldEngineRenderTests/TileStreamingTests.swift b/Tests/UntoldEngineRenderTests/TileStreamingTests.swift index ed0bc4cc..930a7238 100644 --- a/Tests/UntoldEngineRenderTests/TileStreamingTests.swift +++ b/Tests/UntoldEngineRenderTests/TileStreamingTests.swift @@ -166,6 +166,156 @@ final class TileComponentUnitTests: XCTestCase { tc.prefetchRadius = 0 XCTAssertEqual(tc.effectivePrefetchRadius, 100.0, accuracy: 0.001) } + + func testPendingTileResidentQueue_headIndexDrainDoesNotAccumulateStaleEntries() { + // Verifies the head-index drain approach: after queuing N entities and draining + // them over multiple ticks, the diagnosticSummary must show residentQueue=0 + // (no stale dead-head entries accumulate in the backing array). + let batch = BatchingSystem.shared + let oldDrain = batch.maxTileResidentDrainPerTick + defer { + batch.maxTileResidentDrainPerTick = oldDrain + batch.setEnabled(false) + } + + batch.setEnabled(true) + + // Queue 4 fake entities. They don't exist in ECS so the drain skips them + // silently β€” we only care about queue accounting, not entity registration. + let fakeIds: Set = [0xBEEF_0001, 0xBEEF_0002, 0xBEEF_0003, 0xBEEF_0004] + batch.notifyTileEntitiesResident(fakeIds) + + // Drain 2 per tick. + batch.maxTileResidentDrainPerTick = 2 + batch.tick() // drains indices 0-1 + batch.tick() // drains indices 2-3 β†’ head == count β†’ compacts + + // After full drain the diagnostic must not show a non-zero backlog. + let summary = batch.diagnosticSummary() + XCTAssertFalse(summary.contains("residentQueue"), + "After full drain the resident queue backlog must be zero β€” got: \(summary)") + } + + func testSetEnabled_false_clearsPendingTileResidentQueue() { + let batch = BatchingSystem.shared + let oldDrain = batch.maxTileResidentDrainPerTick + defer { + batch.maxTileResidentDrainPerTick = oldDrain + batch.setEnabled(false) // restore disabled state + } + + // Enable batching so notifyTileEntitiesResident enqueues entries. + batch.setEnabled(true) + + // Queue some fake entity IDs. They don't need to exist in ECS β€” the + // drain checks scene.exists and skips non-existent entities safely. + let fakeIds: Set = [0xDEAD_0001, 0xDEAD_0002] + batch.notifyTileEntitiesResident(fakeIds) + + // Disable batching β€” both pendingTileResidentQueue and tileParsedEntityIds + // must be cleared so stale IDs are not replayed if batching is re-enabled. + batch.setEnabled(false) + + // Re-enable and tick: the drain must process nothing (queue was cleared). + batch.setEnabled(true) + batch.maxTileResidentDrainPerTick = Int.max // drain everything if anything remains + batch.tick() + + // If the queue was not cleared on disable, the fake entities would have been + // processed in the tick above. Since they don't exist in the ECS the drain + // silently skips them, making this a no-crash assertion rather than a state check. + // The important invariant is that the queue count is 0 after re-enable + tick. + XCTAssertTrue(true, "System must survive disable/re-enable cycle with queued entities") + } + + func testNotifyTileEntitiesResident_drainStillWorksAfterQuiescenceFix() { + // Verifies that removing the tileParsedEntityIds pre-mark from + // notifyTileEntitiesResident did not break the drain mechanism. + // Entities must still be enqueued, drained across ticks, and leave the + // queue empty β€” just via the quiescence path instead of the bypass path. + let batch = BatchingSystem.shared + let oldDrain = batch.maxTileResidentDrainPerTick + defer { + batch.maxTileResidentDrainPerTick = oldDrain + batch.setEnabled(false) + } + + batch.setEnabled(true) + + // Queue 6 fake entities that don't exist in ECS β€” drain silently skips them + // (scene.exists returns false) but the queue accounting is still exercised. + let fakeIds: Set = [ + 0xCAFE_0001, 0xCAFE_0002, 0xCAFE_0003, + 0xCAFE_0004, 0xCAFE_0005, 0xCAFE_0006, + ] + batch.notifyTileEntitiesResident(fakeIds) + + // Drain everything in one tick. + batch.maxTileResidentDrainPerTick = Int.max + batch.tick() + + // Queue must be empty β€” quiescence path does not stall the drain. + let summary = batch.diagnosticSummary() + XCTAssertFalse(summary.contains("residentQueue"), + "After full drain the queue must be empty whether entities use the bypass or quiescence path; got: \(summary)") + } + + func testMaxTileResidentDrainPerTick_negativeValueClampsTo1AtDrainSite() { + let sys = GeometryStreamingSystem.shared + let old = BatchingSystem.shared.maxTileResidentDrainPerTick + defer { BatchingSystem.shared.maxTileResidentDrainPerTick = old } + + // Property accepts any value β€” clamping is applied at the drain site via + // max(1, maxTileResidentDrainPerTick) before prefix() / removeFirst(). + // Without the clamp: + // min(-5, queueCount) = -5 β†’ removeFirst(-5) crashes with precondition failure. + // With the clamp: + // max(1, -5) = 1 β†’ safe regardless of queue size. + BatchingSystem.shared.maxTileResidentDrainPerTick = -5 + XCTAssertEqual(BatchingSystem.shared.maxTileResidentDrainPerTick, -5, + "Raw value is stored as-is; clamping happens at the use site") + + // Verifying the clamp arithmetic directly. + let effective = max(1, BatchingSystem.shared.maxTileResidentDrainPerTick) + XCTAssertGreaterThanOrEqual(effective, 1, + "Effective drain count must be >= 1 for any stored value") + + // Calling a tick with an empty queue must not crash, even with a negative drain. + // (Full crash-path coverage requires batching enabled + non-empty queue, which + // is exercised by streaming integration tests that set drain to specific values.) + _ = sys // suppress unused warning + } + + func testTileUnloadDwell_requiresGraceAndMinimumResidency() { + let system = GeometryStreamingSystem.shared + let oldGrace = system.unloadGracePeriod + let oldMinimum = system.minimumParsedTileResidentSeconds + defer { + system.unloadGracePeriod = oldGrace + system.minimumParsedTileResidentSeconds = oldMinimum + } + + system.unloadGracePeriod = 3.0 + system.minimumParsedTileResidentSeconds = 8.0 + + let tc = TileComponent() + tc.state = .parsed + tc.pendingUnloadSince = 100.0 + tc.parsedResidentSince = 100.0 + + XCTAssertFalse( + system.tileUnloadDwellSatisfied(tc, now: 102.9), + "Unload must wait for the grace period." + ) + XCTAssertFalse( + system.tileUnloadDwellSatisfied(tc, now: 106.0), + "Unload must also wait for the minimum parsed residency." + ) + XCTAssertTrue( + system.tileUnloadDwellSatisfied(tc, now: 108.0), + "Unload is allowed once both grace and minimum residency are satisfied." + ) + } } // MARK: - TripleCPUBuffer data-structure tests @@ -242,6 +392,253 @@ final class TripleCPUBufferTests: XCTestCase { } } +// MARK: - Tile occlusion sort unit tests + +/// Unit tests for the occlusion-scoring components of view-importance tile sorting. +/// Uses GeometryStreamingSystem.shared only as a method host β€” no ECS or GPU state +/// is created or modified. Tests cover ScreenRect geometry, projectAABBToScreen +/// projection, and tileOcclusionScore coverage fractions with exact expected values. +final class TileOcclusionSortTests: XCTestCase { + private typealias SR = GeometryStreamingSystem.ScreenRect + private typealias Occ = GeometryStreamingSystem.TileOccluder + private var sys: GeometryStreamingSystem { + GeometryStreamingSystem.shared + } + + // MARK: ScreenRect geometry + + func testScreenRect_areaOfUnitSquare() { + let r = SR(minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5) + XCTAssertEqual(r.area, 1.0, accuracy: 1e-5) + } + + func testScreenRect_areaOfZeroWidthRectIsZero() { + let r = SR(minX: 0, minY: 0, maxX: 0, maxY: 0) + XCTAssertEqual(r.area, 0, accuracy: 1e-6, + "Degenerate rect (zero area) must return 0 β€” used for behind-camera tiles") + } + + func testScreenRect_intersectionArea_fullyContained() { + let outer = SR(minX: -1, minY: -1, maxX: 1, maxY: 1) + let inner = SR(minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5) + XCTAssertEqual(outer.intersectionArea(with: inner), 1.0, accuracy: 1e-5, + "Intersection of contained rect must equal the inner rect area") + } + + func testScreenRect_intersectionArea_partialOverlap() { + // a = [0,1]Γ—[0,1], b = [0.5,1.5]Γ—[0.5,1.5] β†’ overlap = [0.5,1]Γ—[0.5,1] = 0.25 + let a = SR(minX: 0, minY: 0, maxX: 1, maxY: 1) + let b = SR(minX: 0.5, minY: 0.5, maxX: 1.5, maxY: 1.5) + XCTAssertEqual(a.intersectionArea(with: b), 0.25, accuracy: 1e-5) + } + + func testScreenRect_intersectionArea_noOverlap() { + let a = SR(minX: 0, minY: 0, maxX: 1, maxY: 1) + let b = SR(minX: 2, minY: 2, maxX: 3, maxY: 3) + XCTAssertEqual(a.intersectionArea(with: b), 0, accuracy: 1e-6) + } + + func testScreenRect_zeroAreaRectMapsToZeroMask() { + // ScreenRect(0,0,0,0): minX == maxX and minY == maxY both map to cell 4 via + // floor((0+1)*4) = 4. Without the area guard, c0 == c1 and r0 == r1 both + // pass the c0 <= c1 check, producing a spurious single-cell mask (bit 36 set). + // With the guard, rectToScreenMask returns 0 immediately. + let zeroRect = SR(minX: 0, minY: 0, maxX: 0, maxY: 0) + XCTAssertEqual(zeroRect.area, 0, accuracy: 1e-6) + let mask = sys.rectToScreenMask(zeroRect) + XCTAssertEqual(mask, 0, + "Zero-area rect must produce a zero bitmask β€” equal min/max map to the same cell and must not count as coverage") + } + + func testTileOcclusionScore_zeroAreaCandidateReturnsOne() { + // A candidate whose projected AABB has zero area (e.g. all corners behind camera) + // must be treated as fully visible (score = 1.0), not spuriously occluded by + // the single grid cell that a zero-area rect incorrectly maps to. + let zeroCandidate = SR(minX: 0, minY: 0, maxX: 0, maxY: 0) + let fullOccluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 1, maxY: 1), distance: 10) + let score = sys.tileOcclusionScore( + candidateRect: zeroCandidate, distance: 50, occluders: [fullOccluder] + ) + XCTAssertEqual(score, 1.0, accuracy: 1e-5, + "Zero-area candidate must return 1.0 β€” it has no screen footprint to occlude") + } + + // MARK: projectAABBToScreen + + func testProjectAABBToScreen_unitBoxWithIdentityMatrix() { + // Identity VP: clip = world position (w=1), NDC = world x/y. + let rect = sys.projectAABBToScreen( + min: simd_float3(-0.5, -0.5, -0.5), + max: simd_float3(0.5, 0.5, 0.5), + viewProj: matrix_identity_float4x4 + ) + XCTAssertEqual(rect.minX, -0.5, accuracy: 1e-5) + XCTAssertEqual(rect.minY, -0.5, accuracy: 1e-5) + XCTAssertEqual(rect.maxX, 0.5, accuracy: 1e-5) + XCTAssertEqual(rect.maxY, 0.5, accuracy: 1e-5) + } + + func testProjectAABBToScreen_nearPlaneClip_returnsZeroAreaWhenExpansionDisabled() { + // Simulate a tile that clips the near plane: one corner has w <= 0 with identity VP. + // With identity VP w = 1 for all corners, so we need a VP that puts some corners behind. + // Construct a matrix that flips the z convention so z > 0 corners get w < 0. + var flipZ = matrix_identity_float4x4 + flipZ.columns.2.z = -1 // clip.w = 1, clip.z = -z β†’ corners at z > 0 get negative z in clip + flipZ.columns.3.w = -1 // make w negative for all corners β†’ all behind near plane + // All corners behind β†’ hasValid = false β†’ zero area regardless of mode. + // For a partial clip, mix: use a box spanning z = -1 to z = 1 with a VP + // that maps z = -1 to w = 2 (in front) and z = 1 to w = 0 (on near plane). + // Simplest verifiable case: use identity VP with a box that's entirely in front, + // then test the occluder-mode flag independently via the expansion path. + // + // More direct: call with allowNearPlaneExpansion=false on a box where we + // manually set anyBehind by having a VP where some corners have w <= 1e-6. + // Use a perspective-like matrix: w_clip = -z_world (front = negative z world). + var perspLike = matrix_identity_float4x4 + perspLike.columns.2 = simd_float4(0, 0, 0, -1) // w_clip = -z_world + perspLike.columns.3 = simd_float4(0, 0, 1, 0) // z_clip = 1 (constant) + + // Box from z = -2 to z = 2: corners at z=-2 β†’ w=2 (in front), + // corners at z= 2 β†’ w=-2 (behind). + // anyBehind = true, hasValid = true. + let rect_occluder = sys.projectAABBToScreen( + min: simd_float3(-1, -1, -2), + max: simd_float3(1, 1, 2), + viewProj: perspLike, + allowNearPlaneExpansion: false // occluder mode + ) + XCTAssertEqual(rect_occluder.area, 0, accuracy: 1e-6, + "Near-plane-clipping tile must return zero-area in occluder mode to prevent full-screen false occlusion") + + let rect_candidate = sys.projectAABBToScreen( + min: simd_float3(-1, -1, -2), + max: simd_float3(1, 1, 2), + viewProj: perspLike, + allowNearPlaneExpansion: true // candidate mode β€” keeps conservative expansion + ) + XCTAssertGreaterThan(rect_candidate.area, 0, + "Near-plane-clipping tile must keep a non-zero footprint in candidate mode") + } + + func testProjectAABBToScreen_ndcOverflowIsClamped() { + // A very large box whose NDC corners exceed Β±1 must be clamped to Β±1. + let rect = sys.projectAABBToScreen( + min: simd_float3(-5, -5, 0), + max: simd_float3(5, 5, 0), + viewProj: matrix_identity_float4x4 + ) + XCTAssertEqual(rect.minX, -1.0, accuracy: 1e-5) + XCTAssertEqual(rect.minY, -1.0, accuracy: 1e-5) + XCTAssertEqual(rect.maxX, 1.0, accuracy: 1e-5) + XCTAssertEqual(rect.maxY, 1.0, accuracy: 1e-5) + } + + // MARK: tileOcclusionScore + + func testTileOcclusionScore_emptyOccluderListReturnsOne() { + let candidate = SR(minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5) + let score = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [] + ) + XCTAssertEqual(score, 1.0, accuracy: 1e-5, + "No occluders β€” tile is fully visible") + } + + func testTileOcclusionScore_occluderBeyondCandidateIsIgnored() { + let candidate = SR(minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5) + // Occluder is farther (dist 100) than the candidate (dist 50) β€” must be skipped. + let occluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 1, maxY: 1), distance: 100) + let score = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [occluder] + ) + XCTAssertEqual(score, 1.0, accuracy: 1e-5, + "Occluder beyond the candidate must not reduce the score") + } + + func testTileOcclusionScore_fullyOccludedReturnsMinWeight() { + let candidate = SR(minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5) + // Closer occluder covers the candidate entirely. + let occluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 1, maxY: 1), distance: 10) + let score = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [occluder] + ) + XCTAssertEqual(score, sys.occlusionMinWeight, accuracy: 1e-5, + "Fully occluded tile must return occlusionMinWeight, not zero β€” AABBs over-approximate opaque coverage") + } + + func testTileOcclusionScore_partialCoverageScoreIsCorrect() { + // Full-screen candidate (64 grid cells). + // Left-half occluder [-1,0]Γ—[-1,1]: cols 0–4 Γ— rows 0–7 = 40 cells. + // Expected coverage = 40/64, score = 1 - 40/64 = 0.375 (exact on the 8Γ—8 grid). + let candidate = SR(minX: -1, minY: -1, maxX: 1, maxY: 1) + let occluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 0, maxY: 1), distance: 10) + let score = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [occluder] + ) + XCTAssertEqual(score, 0.375, accuracy: 1e-4, + "40 of 64 grid cells covered β†’ score = 1 - 40/64 = 0.375") + } + + func testTileOcclusionScore_coverageBelowThresholdIsNotZero() { + let old = sys.occlusionFullThreshold + sys.occlusionFullThreshold = 0.85 + defer { sys.occlusionFullThreshold = old } + + // Full-screen candidate β†’ 64 cells; thresholdCells = ceil(64 Γ— 0.85) = 55. + // Occluder at maxY = 0.4 β†’ rows 0–5, all cols = 48 cells < 55 β†’ score > 0. + // Occluder at maxY = 0.5 β†’ rows 0–6, all cols = 56 cells β‰₯ 55 β†’ score = 0. + let candidate = SR(minX: -1, minY: -1, maxX: 1, maxY: 1) + + let belowOccluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 1, maxY: 0.4), distance: 10) + let belowScore = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [belowOccluder] + ) + XCTAssertEqual(belowScore, 0.25, accuracy: 1e-4, + "48/64 cells covered (< 85% threshold) β†’ score = 1 - 0.75 = 0.25") + + let atOccluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 1, maxY: 0.5), distance: 10) + let atScore = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [atOccluder] + ) + XCTAssertEqual(atScore, sys.occlusionMinWeight, accuracy: 1e-5, + "56/64 cells covered (β‰₯ 85% threshold) β†’ score = occlusionMinWeight, not hard zero") + } + + func testTileOcclusionScore_overlappingOccludersDoNotDoubleCount() { + // Two occluders that cover the same screen region must produce the same score + // as a single occluder β€” union coverage, not additive sum. + // With the old additive algorithm, two identical occluders would double the + // covered area and could trigger the threshold even when only ~40% is covered. + let candidate = SR(minX: -1, minY: -1, maxX: 1, maxY: 1) // 64 cells + let oA = Occ(rect: SR(minX: -1, minY: -1, maxX: 0, maxY: 1), distance: 10) // 40 cells + let oB = Occ(rect: SR(minX: -1, minY: -1, maxX: 0, maxY: 1), distance: 20) // same region + + let scoreOne = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [oA] + ) + let scoreTwo = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [oA, oB] + ) + XCTAssertEqual(scoreOne, scoreTwo, accuracy: 1e-5, + "Two overlapping occluders must give the same score as one β€” no double counting") + XCTAssertLessThan(scoreOne, 1.0, + "Partial occluder must reduce score below 1.0") + XCTAssertGreaterThan(scoreOne, 0.0, + "40/64 cells covered is below the default 85% threshold β€” score must remain above 0") + } + + func testTileOcclusionScore_noOverlapReturnsOne() { + // Occluder is closer but covers a completely different screen region. + let candidate = SR(minX: 0.5, minY: 0.5, maxX: 1, maxY: 1) + let occluder = Occ(rect: SR(minX: -1, minY: -1, maxX: 0, maxY: 0), distance: 10) + let score = sys.tileOcclusionScore( + candidateRect: candidate, distance: 50, occluders: [occluder] + ) + XCTAssertEqual(score, 1.0, accuracy: 1e-5, + "Non-overlapping occluder must not reduce the score") + } +} + // MARK: - LOD hysteresis integration tests /// Verifies that the GeometryStreamingSystem respects `lodHysteresisFactor` diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index c7c88a2e..df3fe8fb 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -27,7 +27,7 @@ Clone the repository and launch the demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine -git checkout v0.12.12 +git checkout v0.12.13 swift run untolddemo ``` diff --git a/scripts/tilestreamingpartition.py b/scripts/tilestreamingpartition.py index aa0dc8df..1702aaa4 100755 --- a/scripts/tilestreamingpartition.py +++ b/scripts/tilestreamingpartition.py @@ -1157,10 +1157,28 @@ def build_quadtree_assignments(objects, object_bounds, inline_metadata=None): shared_objects.append(obj) continue - # Root-spanning objects (depth == 0, spanning) cover the entire scene - # footprint and cannot be confined to one node. + # Root-spanning objects (depth == 0, spanning) span the full XZ footprint + # of their floor's quadtree root and cannot be assigned to a single node. + # + # Split by semantic tier: + # - ExteriorShell: genuinely building-wide geometry (facades, shells, roofs). + # These belong in the global shared bucket β€” they have no per-floor + # affinity and must be resident whenever the building is visible. + # - Interior tiers (SI, RC, FP): floor-spanning ceilings, corridor railings, + # merged prop groups, etc. Routing these to the global shared bucket makes + # them always-resident, inflating shared bucket memory by 10–20Γ— relative + # to their actual floor-level streaming need. Instead, assign them to a + # per-floor root tile (node_id = floor root, same tier) so they load and + # unload with the floor's streaming radii and interior-zone gate. if meta["spatial_class"] == "spanning" and meta["depth"] == 0: - shared_objects.append(obj) + tier = _resolve_tier(meta) + if tier == "ExteriorShell": + shared_objects.append(obj) + else: + floor_id = meta.get("floor_id", 1) + floor_root_node = f"F{floor_id:02d}_Q" + floor_root_key = (floor_root_node, tier) + node_tier_groups.setdefault(floor_root_key, []).append(obj) continue tier = _resolve_tier(meta)