diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e37b1efd..01e22df5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: echo "https://github.com/untoldengine/UntoldEngine" echo "" echo "README:" - echo "https://github.com/untoldengine/UntoldEngine/blob/main/README.md" + echo "https://github.com/untoldengine/UntoldEngine/blob/develop/README.md" echo "" echo "GitHub Discussions:" echo "https://github.com/untoldengine/UntoldEngine/discussions" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf3ffcf..3a62f9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,16 @@ # Changelog +## v0.11.4 - 2026-03-26 +### 🐞 Fixes +- [Patch] Modify build system with new functions (2ef3bf6…) +- [Patch] Fixed concurrency swift 6 issue (f431f47…) +- [Patch] Added guard to avoid creation of zero dim texture (972c139…) +- [Patch] Consolidate loadScene as the primary scene entry point (8befb7b…) +- [Patch] fixed xr ooc crash issues with large models. (a3e42a3…) +- [Patch]Added xrCamera logging message (832c72f…) +### πŸ“š Docs +- [Docs] Updated documents (446dcd9…) +- [Docs] Updated readme (291c8f6…) +- [Docs] added link to showcase (5791988…) ## v0.11.3 - 2026-03-24 ### 🐞 Fixes - [Patch] Request world sensing authorization before starting ARKit session (31eac96…) diff --git a/README.md b/README.md index 59da041c..1c812495 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,12 @@ This is not a drag-and-drop editor-first engine β€” it is a **code-driven engine Check out these videos to see the engine capabilities using the Vision Pro -[Cartoon City Rendering](https://vimeo.com/1176823067?share=copy&fl=sv&fe=ci) +[Cartoon City](https://vimeo.com/1176823067?share=copy&fl=sv&fe=ci) [Game Dungeon](https://vimeo.com/1176823994?share=copy&fl=sv&fe=ci) +[Room](https://vimeo.com/1176995991?fl=ip&fe=ec) + Creator & Lead Developer: http://www.haroldserrano.com diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index 168e01fe..9c9cdf30 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -68,8 +68,17 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// Decrease if zoom-out β†’ zoom-in residency deadlocks persist (far meshes blocking near ones). public var visibleEvictionProtectionRadius: Float = 30.0 + // MARK: - OS Memory Pressure + + /// Set by the MemoryBudgetManager pressure callback (background queue). + /// Checked and cleared at the start of each update() tick (main thread) so that + /// all eviction work stays on the same thread as the rest of the streaming system. + private var pendingPressureRelief: Bool = false + private var pressureIsAggressive: Bool = false + private let stateLock = NSLock() private var timeSinceLastUpdate: Float = 0 + private var timeSinceCameraDiagLog: Float = 0 private var activeLoads: Set = [] /// Subset of activeLoads that belong to the near band. Tracked separately so the /// near-band concurrency limit can be enforced independently of the global limit. @@ -88,7 +97,25 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// Accessed only from update() and its synchronous callees β€” no lock needed. private var firstRangeTimestamps: [EntityID: Double] = [:] - private init() {} + private init() { + // Register OS memory pressure handlers. + // The callbacks fire on a background queue, so we only set a flag here. + // Actual eviction happens on the next update() tick (main thread). + MemoryBudgetManager.shared.onMemoryPressureWarning = { [weak self] in + guard let self else { return } + withStateLock { + self.pendingPressureRelief = true + self.pressureIsAggressive = false + } + } + MemoryBudgetManager.shared.onMemoryPressureCritical = { [weak self] in + guard let self else { return } + withStateLock { + self.pendingPressureRelief = true + self.pressureIsAggressive = true + } + } + } @inline(__always) private func withStateLock(_ body: () throws -> T) rethrows -> T { @@ -164,9 +191,14 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Throttle updates. Switch to a fast tick when there is a pending near-band // backlog so initial hydration bursts drain quickly. Reverts to the normal // updateInterval once the backlog clears. + // OS pressure bypass: if a pressure flag is pending, skip the throttle entirely so + // eviction runs on the very next update() call (≀ 1 frame / ~11 ms at 90 fps). + // Without this, a .critical signal that arrives right after a tick waits up to + // updateInterval (100 ms) before eviction β€” longer than visionOS's kill window. + let hasPendingPressure: Bool = withStateLock { pendingPressureRelief } let effectiveInterval = lastPendingLoadBacklog > 0 ? burstTickInterval : updateInterval timeSinceLastUpdate += deltaTime - guard timeSinceLastUpdate >= effectiveInterval else { + guard timeSinceLastUpdate >= effectiveInterval || hasPendingPressure else { withStateLock { diagnostics.updateFrame = currentFrame diagnostics.updateTriggered = false @@ -182,6 +214,17 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Query with the max unload radius to catch all potentially relevant entities // Transform camera position into entity space (un-shifted by scene root). let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraPosition) + + // 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) + } + let nearbyEntities = OctreeSystem.shared.queryNear(point: effectiveCameraPosition, radius: maxQueryRadius) var loadCandidates: [(EntityID, Float, Int)] = [] // (entity, distance, priority) @@ -274,6 +317,46 @@ public class GeometryStreamingSystem: @unchecked Sendable { var evictionTriggered = false var evictedByLRU = 0 + // OS memory pressure relief β€” flag is set from a background queue callback; + // we drain it here on the main update thread so all eviction stays single-threaded. + var pendingPressure = false + var aggressivePressure = false + withStateLock { + pendingPressure = pendingPressureRelief + aggressivePressure = pressureIsAggressive + pendingPressureRelief = false + pressureIsAggressive = false + } + if pendingPressure { + let maxEntities = aggressivePressure ? 20 : 8 + TextureStreamingSystem.shared.shedTextureMemory( + cameraPosition: effectiveCameraPosition, maxEntities: maxEntities + ) + // Cap per-call evictions to prevent a single pressure event from monopolising + // the frame. 16 entities per pass bounds synchronous unloadMesh work; any + // remaining geometry is evicted on subsequent ticks until pressure clears. + evictedByLRU += evictLRU(cameraPosition: effectiveCameraPosition, maxEvictions: 16) + if aggressivePressure { + // Second pass for critical pressure: push harder to free geometry. + evictedByLRU += evictLRU(cameraPosition: effectiveCameraPosition, maxEvictions: 16) + + // Release CPU-heap (MDLAsset + CPUMeshEntry buffers) for all warm OOC roots. + // evictLRU only frees GPU Metal buffers tracked by MemoryBudgetManager; the OS + // measures total process memory, which includes the CPU mesh heap that + // ProgressiveAssetLoader keeps alive. Releasing it here can free several hundred + // MB on a heavy geometry scene. The rehydration context (URL + policy) survives, + // so re-approach triggers a transparent cold re-stream from disk. + let warmRoots = ProgressiveAssetLoader.shared.allWarmRootEntityIds() + for rootId in warmRoots { + ProgressiveAssetLoader.shared.releaseWarmAsset(rootEntityId: rootId) + } + if !warmRoots.isEmpty { + print("[GeometryStreaming] Critical pressure: released CPU heap for \(warmRoots.count) OOC root(s)") + } + } + evictionTriggered = true + } + // Texture-first relief: if combined GPU memory (mesh + texture) is high but // geometry alone is not, downgrade textures on distant entities before // considering geometry eviction. A texture resolution drop on a far wall is @@ -596,7 +679,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { let transform = scene.get(component: WorldTransformComponent.self, for: entityId), let local = scene.get(component: LocalTransformComponent.self, for: entityId) { - let cameraPos = cameraComponent.localPosition + let cameraPos = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) let center = (local.boundingBox.min + local.boundingBox.max) * 0.5 let worldCenter = transform.space * simd_float4(center, 1.0) let distance = simd_distance(cameraPos, simd_float3(worldCenter.x, worldCenter.y, worldCenter.z)) @@ -706,7 +789,16 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Register Metal allocation with the budget manager so shouldEvict() sees these // GPU bytes. Without this the budget gate in update() is blind to out-of-core uploads // and will never throttle them β€” defeating the memory-pressure guard entirely. + // Texture bytes are estimated rather than exact: TextureStreamingSystem will update + // the value with the real figure once streaming completes. Even an estimate is far + // better than 0 β€” it closes the tracking gap that lets the budget over-admit entities. let meshSize = calculateMeshArrayMemory(namedMeshes) + // Register 0 for texture bytes at upload time. With independent geometry/texture + // budget pools, the geometry gate (canAcceptMesh / shouldEvictGeometry) is unaffected + // by texture usage, so a zero estimate no longer causes over-admission. The estimate + // (4 MB Γ— slots) massively over-filled the texture pool on geometry-heavy scenes, + // making shouldEvict() permanently true and triggering no-op shedTextureMemory calls + // every tick. TextureStreamingSystem registers the real value after streaming. MemoryBudgetManager.shared.registerMesh( entityId: entityId, meshSizeBytes: meshSize, @@ -808,7 +900,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { let transform = scene.get(component: WorldTransformComponent.self, for: entityId), let local = scene.get(component: LocalTransformComponent.self, for: entityId) { - let cameraPos = cameraComponent.localPosition + let cameraPos = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) let center = (local.boundingBox.min + local.boundingBox.max) * 0.5 let worldCenter = transform.space * simd_float4(center, 1.0) let distance = simd_distance(cameraPos, simd_float3(worldCenter.x, worldCenter.y, worldCenter.z)) @@ -832,6 +924,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { } // Register total GPU allocation (all levels) with the budget manager. + // Texture bytes registered as 0 β€” see uploadFromCPUEntry for the reasoning. + // TextureStreamingSystem replaces this with the real value after streaming. let totalMeshSize = uploadedMeshes.values.reduce(0) { $0 + calculateMeshArrayMemory($1) } MemoryBudgetManager.shared.registerMesh(entityId: entityId, meshSizeBytes: totalMeshSize, textureSizeBytes: 0) @@ -1046,18 +1140,63 @@ public class GeometryStreamingSystem: @unchecked Sendable { registerRenderComponent(entityId: entityId, meshes: entityMeshes, url: url, assetName: meshName) } - // Register with memory budget + // Register with memory budget. + // Mesh objects from MeshResourceManager carry actual MTLTexture allocation sizes, + // so use the real texture footprint here rather than a placeholder zero. let meshSize = calculateMeshArrayMemory(meshes) + let textureSize = meshes.reduce(0) { $0 + $1.textureMemorySize } MemoryBudgetManager.shared.registerMesh( entityId: entityId, meshSizeBytes: meshSize, - textureSizeBytes: 0 + textureSizeBytes: textureSize ) } return true } + /// Estimate GPU texture memory for an MDLObject by counting texture slots in its materials. + /// + /// Uses a conservative 1024Γ—1024 RGBA (4 bytes/pixel) placeholder per slot. The actual GPU + /// cost depends on compression (ASTC/BCn) and mip-map count, so this is an upper bound + /// rather than an exact value. Even a coarse estimate is far better than zero β€” it closes + /// the budget tracking gap between upload time and first texture stream. + /// + /// Call this after `ensureTexturesLoaded()` so that MDLMaterialProperty slots carry + /// `.texture` values for USDZ-embedded images. + private func estimateTextureSizeBytes(from object: MDLObject) -> Int { + let textureSemantics: [MDLMaterialSemantic] = [ + .baseColor, .emission, .tangentSpaceNormal, .roughness, .metallic, + .ambientOcclusion, .opacity, .bump, .specular, .displacement, + ] + var textureSlots = 0 + + func scan(_ obj: MDLObject) { + if let mesh = obj as? MDLMesh, + let submeshes = mesh.submeshes?.compactMap({ $0 as? MDLSubmesh }) + { + for submesh in submeshes { + guard let material = submesh.material else { continue } + for semantic in textureSemantics { + if let prop = material.property(with: semantic), + prop.type == .texture || prop.type == .URL + { + textureSlots += 1 + } + } + } + } + let childObjects = obj.children.objects + for i in 0 ..< childObjects.count { + scan(childObjects[i]) + } + } + scan(object) + + // 1024 Γ— 1024 Γ— 4 bytes (RGBA uncompressed) per slot β€” conservative upper bound. + return textureSlots * (1024 * 1024 * 4) + } + private func unloadMesh(entityId: EntityID) { guard let streaming = scene.get(component: StreamingComponent.self, for: entityId), streaming.state == .loaded @@ -1132,7 +1271,7 @@ public class GeometryStreamingSystem: @unchecked Sendable { /// are never evicted while visible (prevents foreground popping). Entities beyond that /// radius may be evicted even while visible β€” a distant pop is cheaper than a nearby /// mesh failing to load under memory pressure. - private func evictLRU(cameraPosition: simd_float3) -> Int { + private func evictLRU(cameraPosition: simd_float3, maxEvictions: Int = Int.max) -> Int { // First, evict any unused cached files MeshResourceManager.shared.evictUnused() @@ -1140,7 +1279,10 @@ public class GeometryStreamingSystem: @unchecked Sendable { var staleEntityIds: [EntityID] = [] let trackedLoadedSnapshot = loadedStreamingEntitiesSnapshot() - let budget = Float(max(1, MemoryBudgetManager.shared.meshBudget)) + // Use geometryBudget as the denominator: evictLRU is purely a geometry eviction + // pass, so sizing the score against the geometry pool (not the combined budget) + // gives an accurate picture of how much of that pool each candidate consumes. + let budget = Float(max(1, MemoryBudgetManager.shared.geometryBudget)) for entityId in trackedLoadedSnapshot { guard scene.exists(entityId) else { @@ -1179,6 +1321,11 @@ public class GeometryStreamingSystem: @unchecked Sendable { // geometry evictions. guard MemoryBudgetManager.shared.shouldEvictGeometry() else { break } + // Per-call cap: spreads large eviction bursts across multiple ticks so a single + // pressure event cannot monopolise the frame. Remaining entities are evicted on + // subsequent ticks (each still passes the shouldEvictGeometry() check above). + if evictedCount >= maxEvictions { break } + // Distance-aware visibility guard. // Close visible meshes (< visibleEvictionProtectionRadius) are protected β€” evicting // them would cause an obvious foreground pop. Far visible meshes are evictable under diff --git a/Sources/UntoldEngine/Systems/LODSystem.swift b/Sources/UntoldEngine/Systems/LODSystem.swift index 98e8c262..3c83ca69 100644 --- a/Sources/UntoldEngine/Systems/LODSystem.swift +++ b/Sources/UntoldEngine/Systems/LODSystem.swift @@ -21,7 +21,7 @@ public class LODSystem: @unchecked Sendable { let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { return } - let cameraPosition = cameraComponent.localPosition + let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) // Query entities with LOD components let lodId = getComponentId(for: LODComponent.self) diff --git a/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift b/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift index 67153102..869e4b5a 100644 --- a/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift +++ b/Sources/UntoldEngine/Systems/MemoryBudgetManager.swift @@ -12,6 +12,9 @@ import Foundation import Metal import simd +#if canImport(Darwin) + import Darwin +#endif /// Current memory usage statistics public struct MemoryStats { @@ -26,10 +29,30 @@ public struct MemoryStats { meshMemoryUsed + textureMemoryUsed } - /// Configured budget limit in bytes - public var budgetLimit: Int + /// Geometry-only budget (vertex + index buffers) + public var geometryBudget: Int + + /// Texture-only budget + public var textureBudget: Int + + /// Combined budget limit in bytes + public var budgetLimit: Int { + geometryBudget + textureBudget + } + + /// Geometry pool utilization (0.0 – 1.0+) + public var geometryUtilization: Float { + guard geometryBudget > 0 else { return 0 } + return Float(meshMemoryUsed) / Float(geometryBudget) + } + + /// Texture pool utilization (0.0 – 1.0+) + public var textureUtilization: Float { + guard textureBudget > 0 else { return 0 } + return Float(textureMemoryUsed) / Float(textureBudget) + } - /// Current utilization as percentage (0.0 - 1.0+) + /// Combined utilization as percentage (0.0 - 1.0+) public var utilizationPercent: Float { guard budgetLimit > 0 else { return 0 } return Float(totalTrackedMemory) / Float(budgetLimit) @@ -43,20 +66,22 @@ public struct MemoryStats { max(0, budgetLimit - totalTrackedMemory) } - /// Whether memory pressure is high + /// Whether either pool is under pressure (geometry or texture pool at β‰₯ 85 %) public var isUnderPressure: Bool { - utilizationPercent >= 0.85 + geometryUtilization >= 0.85 || textureUtilization >= 0.85 } public init( meshMemoryUsed: Int = 0, textureMemoryUsed: Int = 0, - budgetLimit: Int = 0, + geometryBudget: Int = 0, + textureBudget: Int = 0, trackedEntityCount: Int = 0 ) { self.meshMemoryUsed = meshMemoryUsed self.textureMemoryUsed = textureMemoryUsed - self.budgetLimit = budgetLimit + self.geometryBudget = geometryBudget + self.textureBudget = textureBudget self.trackedEntityCount = trackedEntityCount } } @@ -84,10 +109,35 @@ public class MemoryBudgetManager: @unchecked Sendable { // MARK: - Configuration - /// Maximum mesh memory budget in bytes (default: 512 MB) - public var meshBudget: Int = 512 * 1024 * 1024 { + /// Geometry (vertex + index) memory budget in bytes. + /// + /// Managed independently from texture memory so texture upgrades cannot block + /// mesh loads and vice versa. Set automatically at init from device capabilities. + public var geometryBudget: Int = 300 * 1024 * 1024 { + didSet { + Logger.log(message: "MemoryBudgetManager: Geometry budget β†’ \(geometryBudget.formattedAsMemory)") + } + } + + /// Texture memory budget in bytes. + /// + /// Managed independently from geometry memory. + public var textureBudget: Int = 200 * 1024 * 1024 { didSet { - Logger.log(message: "MemoryBudgetManager: Budget set to \(meshBudget.formattedAsMemory)") + Logger.log(message: "MemoryBudgetManager: Texture budget β†’ \(textureBudget.formattedAsMemory)") + } + } + + /// Combined geometry + texture budget β€” read/write alias for backward compatibility. + /// + /// Getting returns `geometryBudget + textureBudget`. + /// Setting splits the value 60 % geometry / 40 % texture, which preserves the + /// existing semantics for test code and external callers that set a single number. + public var meshBudget: Int { + get { geometryBudget + textureBudget } + set { + geometryBudget = Int(Double(newValue) * 0.60) + textureBudget = Int(Double(newValue) * 0.40) } } @@ -100,6 +150,24 @@ public class MemoryBudgetManager: @unchecked Sendable { /// Whether the manager is enabled public var enabled: Bool = true + // MARK: - OS Memory Pressure + + /// Called when the OS signals warning-level memory pressure. + /// + /// Register a handler from `GeometryStreamingSystem` to proactively shed textures + /// and evict geometry before the OS escalates to critical. The handler is invoked + /// on a background queue β€” use a flag to defer actual eviction to the next update tick. + public var onMemoryPressureWarning: (() -> Void)? + + /// Called when the OS signals critical memory pressure. + /// + /// Should trigger aggressive eviction. Invoked on a background queue. + public var onMemoryPressureCritical: (() -> Void)? + + /// Retained for the lifetime of the singleton. Never cancelled β€” `MemoryBudgetManager` + /// is app-lifetime, so there is no dealloc path that would require cleanup. + private var pressureSource: DispatchSourceMemoryPressure? + // MARK: - State /// Memory entries indexed by entity ID @@ -124,36 +192,81 @@ public class MemoryBudgetManager: @unchecked Sendable { /// `updateTextureSizeBytes`) once the async work completes or fails. private var inFlightTextureReservation: Int = 0 + /// Last combined-utilization bucket that was logged (0, 50, 75, 85, or 95). + /// Prevents log spam by only emitting a line when crossing a threshold. + private var lastLoggedThresholdBucket: Int = 0 + /// Thread safety lock private let lock = NSLock() // MARK: - Initialization private init() { - // Attempt to set budget based on device capabilities configureDefaultBudget() + startMemoryPressureMonitoring() } - /// Configure budget based on device GPU memory + /// Configure geometry and texture budgets based on device capabilities. + /// + /// On macOS, anchors to `MTLDevice.recommendedMaxWorkingSetSize` (the GPU's safe + /// working-set size for the unified memory architecture). + /// On visionOS / iOS, probes `os_proc_available_memory()` β€” the remaining process + /// footprint before the OS kills the app β€” and takes 40 % as a conservative GPU + /// sub-budget. The total is split 60 % geometry / 40 % texture so each pool has + /// an independent ceiling. private func configureDefaultBudget() { - // Metal doesn't expose total GPU memory directly - // Use conservative defaults based on typical device classes + let totalBudget: Int + #if os(macOS) - // macOS typically has more GPU memory - meshBudget = 1024 * 1024 * 1024 // 1 GB - #elseif os(visionOS) - // Vision Pro (M2/M4) has 16 GB unified memory; geometry + textures - // share this budget, so give ample room for both. - meshBudget = 1536 * 1024 * 1024 // 1.5 GB - #elseif os(iOS) - // iOS devices vary widely, use conservative default - if ProcessInfo.processInfo.physicalMemory > 4 * 1024 * 1024 * 1024 { - meshBudget = 512 * 1024 * 1024 // 512 MB for high-end + if let device = MTLCreateSystemDefaultDevice() { + let gpuWorking = Int(device.recommendedMaxWorkingSetSize) + // 40 % of the GPU working set is a safe OOC sub-budget on unified memory. + totalBudget = max(512 * 1024 * 1024, Int(Double(gpuWorking) * 0.40)) } else { - meshBudget = 256 * 1024 * 1024 // 256 MB for lower-end + totalBudget = 1024 * 1024 * 1024 // 1 GB fallback } + #elseif canImport(Darwin) + // os_proc_available_memory() returns remaining app footprint before kill. + // 40 % of that is a safe GPU sub-budget; clamp to [512 MB, 3 GB]. + let available = Int(os_proc_available_memory()) + let probed = Int(Double(available) * 0.40) + totalBudget = max(512 * 1024 * 1024, min(probed, 3 * 1024 * 1024 * 1024)) #else - meshBudget = 512 * 1024 * 1024 // 512 MB default + totalBudget = 512 * 1024 * 1024 + #endif + + // Independent pools: 60 % geometry, 40 % texture. + geometryBudget = Int(Double(totalBudget) * 0.60) + textureBudget = Int(Double(totalBudget) * 0.40) + Logger.log(message: "MemoryBudgetManager: probed budgets β€” geometry=\(geometryBudget.formattedAsMemory) texture=\(textureBudget.formattedAsMemory) total=\(meshBudget.formattedAsMemory)") + } + + /// Subscribe to OS memory pressure events and fire the registered callbacks. + /// + /// On `.warning`: calls `onMemoryPressureWarning` β€” expected to shed textures and + /// evict geometry proactively before the OS escalates. + /// On `.critical`: calls `onMemoryPressureCritical` β€” expected to aggressively free + /// memory to avoid process termination. + /// Both callbacks are invoked on a background queue; handlers must be thread-safe. + private func startMemoryPressureMonitoring() { + #if os(macOS) || os(iOS) || os(visionOS) + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], + queue: .global(qos: .userInitiated) + ) + source.setEventHandler { [weak self] in + guard let self else { return } + let event = source.data + if event.contains(.critical) { + Logger.log(message: "[MemoryBudgetManager] OS critical memory pressure β€” triggering aggressive eviction") + onMemoryPressureCritical?() + } else if event.contains(.warning) { + Logger.log(message: "[MemoryBudgetManager] OS warning memory pressure β€” triggering proactive eviction") + onMemoryPressureWarning?() + } + } + source.resume() + pressureSource = source #endif } @@ -175,8 +288,6 @@ public class MemoryBudgetManager: @unchecked Sendable { guard enabled else { return } lock.lock() - defer { lock.unlock() } - // Remove existing entry if present (handles updates) if let existing = memoryEntries[entityId] { totalMeshMemory -= existing.meshSizeBytes @@ -194,6 +305,9 @@ public class MemoryBudgetManager: @unchecked Sendable { memoryEntries[entityId] = entry totalMeshMemory += meshSizeBytes totalTextureMemory += textureSizeBytes + lock.unlock() + + checkThresholdLogging() } /// Register mesh memory by calculating size from mesh array @@ -264,20 +378,27 @@ public class MemoryBudgetManager: @unchecked Sendable { return MemoryStats( meshMemoryUsed: totalMeshMemory, textureMemoryUsed: totalTextureMemory, - budgetLimit: meshBudget, + geometryBudget: geometryBudget, + textureBudget: textureBudget, trackedEntityCount: memoryEntries.count ) } - /// Check if we should start evicting entities (combined geometry + texture budget). + /// Check if we should start evicting entities. + /// + /// Returns `true` when either the geometry pool or the texture pool has reached + /// its individual high-water mark. This fires texture relief (downgrade distant + /// textures) before considering geometry eviction, so the two pools are managed + /// independently rather than competing for the same ceiling. public func shouldEvict() -> Bool { guard enabled else { return false } lock.lock() defer { lock.unlock() } - let utilization = Float(totalMeshMemory + totalTextureMemory) / Float(meshBudget) - return utilization >= highWaterMark + let geomUtil = Float(totalMeshMemory) / Float(max(1, geometryBudget)) + let texUtil = Float(totalTextureMemory) / Float(max(1, textureBudget)) + return geomUtil >= highWaterMark || texUtil >= highWaterMark } /// Check if geometry memory alone has hit the high-water mark. @@ -291,65 +412,62 @@ public class MemoryBudgetManager: @unchecked Sendable { lock.lock() defer { lock.unlock() } - let utilization = Float(totalMeshMemory) / Float(meshBudget) + let utilization = Float(totalMeshMemory) / Float(max(1, geometryBudget)) return utilization >= highWaterMark } /// Check if we can accept a new mesh of the given size. /// - /// Accounts for both geometry and texture memory already tracked so the - /// combined GPU footprint stays within the budget after the new allocation. + /// Legacy combined check: passes if the new bytes fit within the sum of both pools. + /// Prefer `canAcceptMesh` or `canAcceptTexture` for pool-specific gates. public func canAccept(sizeBytes: Int) -> Bool { lock.lock() defer { lock.unlock() } - return (totalMeshMemory + totalTextureMemory + sizeBytes) <= meshBudget + return (totalMeshMemory + totalTextureMemory + sizeBytes) <= (geometryBudget + textureBudget) } - /// Check if a new mesh of the given size fits within the geometry portion of the budget. + /// Check if a new mesh of the given size fits within the geometry pool. /// - /// Only counts geometry (vertex/index) memory β€” texture memory is excluded so that - /// texture upgrades cannot prevent mesh loads. Used by `GeometryStreamingSystem` - /// for its per-candidate pre-emptive budget reservation. + /// Only counts geometry (vertex/index) memory against `geometryBudget` β€” texture + /// memory is excluded so texture upgrades cannot prevent mesh loads. + /// Used by `GeometryStreamingSystem` for per-candidate pre-emptive budget reservation. public func canAcceptMesh(sizeBytes: Int) -> Bool { lock.lock() defer { lock.unlock() } - return (totalMeshMemory + sizeBytes) <= meshBudget + return (totalMeshMemory + sizeBytes) <= geometryBudget } /// Check if we can accept a new texture allocation of the given size. /// - /// Includes in-flight reservations (`inFlightTextureReservation`) so callers see - /// headroom already committed to in-progress upgrade Tasks. Use `reserveTexture` - /// instead of this method when you intend to actually start an upgrade β€” `reserveTexture` - /// atomically checks and reserves in one lock acquisition, eliminating the TOCTOU race - /// between checking and acting. + /// Checks against `textureBudget` (independent of geometry pool) and includes + /// in-flight reservations so callers see already-committed headroom. + /// Use `reserveTexture` when you intend to actually start an upgrade β€” it + /// atomically checks and reserves, eliminating the TOCTOU race. public func canAcceptTexture(sizeBytes: Int) -> Bool { lock.lock() defer { lock.unlock() } - return (totalMeshMemory + totalTextureMemory + inFlightTextureReservation + sizeBytes) <= meshBudget + return (totalTextureMemory + inFlightTextureReservation + sizeBytes) <= textureBudget } - /// Atomically check whether a texture upgrade fits within the budget and, if so, - /// reserve the estimated bytes. + /// Atomically check whether a texture upgrade fits within `textureBudget` and, + /// if so, reserve the estimated bytes. /// /// Unlike `canAcceptTexture` + a separate action, this method eliminates the /// check-then-act race: two concurrent callers cannot both see "budget available" /// and both proceed, because the first reservation reduces the apparent headroom /// for the second caller before it acquires the lock. /// - /// - Parameter sizeBytes: Estimated GPU bytes for the upgrade batch (from - /// `TextureStreamingSystem.estimatedUpgradeBytes`). - /// - Returns: `true` if the reservation was accepted; `false` if the budget would - /// be exceeded. If `false`, the caller should proceed with downgrades only and - /// must NOT call `releaseTextureReservation`. + /// - Parameter sizeBytes: Estimated GPU bytes for the upgrade batch. + /// - Returns: `true` if the reservation was accepted; `false` if the texture budget + /// would be exceeded. If `false`, do NOT call `releaseTextureReservation`. public func reserveTexture(sizeBytes: Int) -> Bool { lock.lock() defer { lock.unlock() } - guard (totalMeshMemory + totalTextureMemory + inFlightTextureReservation + sizeBytes) <= meshBudget else { + guard (totalTextureMemory + inFlightTextureReservation + sizeBytes) <= textureBudget else { return false } inFlightTextureReservation += sizeBytes @@ -359,9 +477,8 @@ public class MemoryBudgetManager: @unchecked Sendable { /// Release a previously accepted texture upgrade reservation. /// /// Call this after the async upgrade Task completes (success, failure, or cancellation), - /// before or concurrently with `updateTextureSizeBytes`. Releasing the reservation - /// restores the apparent headroom for subsequent `canAcceptTexture` / `reserveTexture` - /// calls. Only call this if `reserveTexture` returned `true`. + /// before or concurrently with `updateTextureSizeBytes`. Only call if `reserveTexture` + /// returned `true`. public func releaseTextureReservation(sizeBytes: Int) { lock.lock() inFlightTextureReservation = max(0, inFlightTextureReservation - sizeBytes) @@ -372,18 +489,21 @@ public class MemoryBudgetManager: @unchecked Sendable { /// /// Called by `TextureStreamingSystem` whenever a texture upgrade or downgrade /// finishes so `totalTextureMemory` reflects the actual current GPU allocation. - /// No-op if the entity is not currently tracked (e.g. entity was evicted while - /// the streaming Task was in-flight). + /// No-op if the entity is not currently tracked. public func updateTextureSizeBytes(entityId: EntityID, newSizeBytes: Int) { lock.lock() - defer { lock.unlock() } - - guard var entry = memoryEntries[entityId] else { return } + guard var entry = memoryEntries[entityId] else { + lock.unlock() + return + } totalTextureMemory -= entry.textureSizeBytes entry.textureSizeBytes = newSizeBytes entry.lastUsedFrame = currentFrame totalTextureMemory += newSizeBytes memoryEntries[entityId] = entry + lock.unlock() + + checkThresholdLogging() } /// Get memory size for an entity (if tracked) @@ -445,14 +565,17 @@ public class MemoryBudgetManager: @unchecked Sendable { /// Get candidates to evict to reach the low water mark. /// - /// Uses combined geometry + texture memory to compute how much needs to be - /// freed, and counts each candidate's total size (mesh + texture) toward the - /// target. This ensures eviction correctly accounts for texture-heavy entities. + /// Intentionally uses the combined geometry + texture budget and combined entity + /// sizes. Evicting an entity releases both its geometry and texture bytes at once, + /// so accounting against a single combined target is correct and avoids the + /// complexity of running two separate per-pool passes. Per-pool targets would risk + /// double-evicting entities that contribute to both pools simultaneously. public func getEvictionCandidatesToTarget() -> [EntityID] { lock.lock() defer { lock.unlock() } - let targetMemory = Int(Float(meshBudget) * lowWaterMark) + let totalBudget = geometryBudget + textureBudget + let targetMemory = Int(Float(totalBudget) * lowWaterMark) var memoryToFree = (totalMeshMemory + totalTextureMemory) - targetMemory guard memoryToFree > 0 else { return [] } @@ -484,6 +607,40 @@ public class MemoryBudgetManager: @unchecked Sendable { .map(\.entityId) } + // MARK: - Threshold Logging + + /// Log a single line when combined utilization crosses 75 %, 85 %, or 95 %. + /// + /// Called without the lock after `registerMesh` and `updateTextureSizeBytes` so + /// the lock is never held during logging. There is a benign race window between + /// the two lock acquisitions, which is acceptable since this path is for diagnostics + /// only β€” it never drives a budget decision. + private func checkThresholdLogging() { + lock.lock() + let meshMem = totalMeshMemory + let texMem = totalTextureMemory + let geomBudget = geometryBudget + let texBudget = textureBudget + let lastBucket = lastLoggedThresholdBucket + lock.unlock() + + let combined = geomBudget + texBudget + let combinedPct = combined > 0 ? Int(Float(meshMem + texMem) * 100 / Float(combined)) : 0 + let newBucket: Int = combinedPct >= 95 ? 95 + : combinedPct >= 85 ? 85 + : combinedPct >= 75 ? 75 : 0 + + guard newBucket != lastBucket else { return } + + lock.lock() + lastLoggedThresholdBucket = newBucket + lock.unlock() + + let geomPct = geomBudget > 0 ? Int(Float(meshMem) * 100 / Float(geomBudget)) : 0 + let texPct = texBudget > 0 ? Int(Float(texMem) * 100 / Float(texBudget)) : 0 + Logger.log(message: "[MemoryBudgetManager] \(combinedPct)% combined β€” geom \(geomPct)%/\(geomBudget.formattedAsMemory), tex \(texPct)%/\(texBudget.formattedAsMemory)") + } + // MARK: - Utilities /// Clear all tracked memory (call when scene is unloaded) @@ -495,6 +652,7 @@ public class MemoryBudgetManager: @unchecked Sendable { totalMeshMemory = 0 totalTextureMemory = 0 inFlightTextureReservation = 0 + lastLoggedThresholdBucket = 0 } /// Get number of tracked entities @@ -518,10 +676,9 @@ public class MemoryBudgetManager: @unchecked Sendable { let stats = getStats() Logger.log(message: """ MemoryBudgetManager Status: - - Mesh Memory: \(stats.meshMemoryUsed.formattedAsMemory) - - Texture Memory: \(stats.textureMemoryUsed.formattedAsMemory) - - Total GPU Memory: \(stats.totalTrackedMemory.formattedAsMemory) / \(stats.budgetLimit.formattedAsMemory) - - Utilization: \(String(format: "%.1f%%", stats.utilizationPercent * 100)) + - Mesh Memory: \(stats.meshMemoryUsed.formattedAsMemory) / \(stats.geometryBudget.formattedAsMemory) (\(String(format: "%.1f%%", stats.geometryUtilization * 100))) + - Texture Memory: \(stats.textureMemoryUsed.formattedAsMemory) / \(stats.textureBudget.formattedAsMemory) (\(String(format: "%.1f%%", stats.textureUtilization * 100))) + - Total GPU Memory: \(stats.totalTrackedMemory.formattedAsMemory) / \(stats.budgetLimit.formattedAsMemory) (\(String(format: "%.1f%%", stats.utilizationPercent * 100))) - Tracked Entities: \(stats.trackedEntityCount) - Under Pressure: \(stats.isUnderPressure) """) @@ -532,28 +689,32 @@ public class MemoryBudgetManager: @unchecked Sendable { public extension MemoryBudgetManager { /// Apply low-memory preset (mobile/older devices) func applyLowMemoryPreset() { - meshBudget = 256 * 1024 * 1024 // 256 MB + geometryBudget = Int(Double(256 * 1024 * 1024) * 0.60) + textureBudget = Int(Double(256 * 1024 * 1024) * 0.40) highWaterMark = 0.80 lowWaterMark = 0.60 } /// Apply standard preset (most devices) func applyStandardPreset() { - meshBudget = 512 * 1024 * 1024 // 512 MB + geometryBudget = Int(Double(512 * 1024 * 1024) * 0.60) + textureBudget = Int(Double(512 * 1024 * 1024) * 0.40) highWaterMark = 0.85 lowWaterMark = 0.70 } /// Apply high-memory preset (desktop/high-end) func applyHighMemoryPreset() { - meshBudget = 1024 * 1024 * 1024 // 1 GB + geometryBudget = Int(Double(1024 * 1024 * 1024) * 0.60) + textureBudget = Int(Double(1024 * 1024 * 1024) * 0.40) highWaterMark = 0.90 lowWaterMark = 0.75 } /// Apply unlimited preset (no eviction) func applyUnlimitedPreset() { - meshBudget = Int.max / 2 // Effectively unlimited + geometryBudget = Int.max / 4 + textureBudget = Int.max / 4 highWaterMark = 1.0 lowWaterMark = 1.0 } diff --git a/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift b/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift index 9dcf5b3d..a2428401 100644 --- a/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift +++ b/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift @@ -353,6 +353,14 @@ public final class ProgressiveAssetLoader: @unchecked Sendable { /// Transition a root entity from CPU-warm to CPU-cold. /// + /// Returns the IDs of all root assets that are currently CPU-warm (MDLAsset still in RAM). + /// Used by GeometryStreamingSystem to release CPU heap under critical memory pressure. + public func allWarmRootEntityIds() -> [EntityID] { + lock.lock() + defer { lock.unlock() } + return Array(rootAssetRefs.keys) + } + /// Releases the `MDLAsset` and all child `CPUMeshEntry` objects, freeing CPU-heap memory. /// The `RootRehydrationContext` is retained so `GeometryStreamingSystem` can re-parse /// the asset from disk when the entity next enters streaming range. diff --git a/Sources/UntoldEngine/Utils/Logger.swift b/Sources/UntoldEngine/Utils/Logger.swift index 5d52a5ff..be13c51b 100644 --- a/Sources/UntoldEngine/Utils/Logger.swift +++ b/Sources/UntoldEngine/Utils/Logger.swift @@ -28,6 +28,7 @@ public enum LogCategory: String, CaseIterable, Sendable { case assetLoader = "AssetLoader" case engineStats = "EngineStats" case integration = "Integration" + case xrCamera = "XRCamera" } public struct LogEvent: Identifiable, Sendable { diff --git a/Sources/UntoldEngineXR/UntoldEngineXR.swift b/Sources/UntoldEngineXR/UntoldEngineXR.swift index fbad5e5d..f5562409 100644 --- a/Sources/UntoldEngineXR/UntoldEngineXR.swift +++ b/Sources/UntoldEngineXR/UntoldEngineXR.swift @@ -338,11 +338,16 @@ // 5. Perform any rendering-related work that doesn't rely on the device anchor info guard let renderer else { return } + + // Always sync the physical headset position into ECS camera components, even while + // assets are loading. syncStreamingCameraPosition only writes three fields on the + // camera entity β€” it never touches mesh entities being mutated by the loading pipeline, + // so there is no race. Without this, the streaming systems resume from the pre-load + // camera position after a long load, causing a one-tick position jump for any distance + // that accumulated while the user was walking during the load. + syncStreamingCameraPosition() + if !loading { - // Sync the physical headset position into ECS camera components before - // streaming/LOD systems run inside updateXR(). Without this, all four OOC - // systems see a frozen default eye position regardless of where the user walks. - syncStreamingCameraPosition() renderer.updateXR(useExternalStatsLifecycle: true) } diff --git a/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift b/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift index f9239548..5f29acfb 100644 --- a/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift +++ b/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift @@ -41,7 +41,11 @@ final class GeometryStreamingEvictionTests: BaseRenderSetup { // Clear budget so base-setup entity registrations don't interfere. MemoryBudgetManager.shared.clear() MemoryBudgetManager.shared.enabled = true - MemoryBudgetManager.shared.meshBudget = 100 * 1024 * 1024 // 100 MB + // GeometryStreamingSystem eviction uses the geometry pool only. Setting the + // combined meshBudget would split 100 MB into 60 MB geometry / 40 MB texture, + // which breaks the documented arithmetic in the value-score eviction tests. + MemoryBudgetManager.shared.geometryBudget = 100 * 1024 * 1024 // 100 MB + MemoryBudgetManager.shared.textureBudget = 0 MemoryBudgetManager.shared.highWaterMark = 0.85 MemoryBudgetManager.shared.lowWaterMark = 0.70 diff --git a/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift b/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift index 0ed4aa72..3dc44dbd 100644 --- a/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift +++ b/Tests/UntoldEngineTests/MemoryBudgetManagerTests.swift @@ -108,12 +108,15 @@ final class MemoryBudgetManagerTests: XCTestCase { // MARK: - Eviction Tests func testShouldEvict() { - // Below threshold - manager.registerMesh(entityId: 1, meshSizeBytes: 80 * 1024 * 1024) // 80% + // shouldEvict() fires when the geometry pool OR texture pool reaches 85%. + // With meshBudget = 100 MB: geometryBudget = 60 MB, textureBudget = 40 MB. + + // Geometry at 80% of its pool (48 MB / 60 MB) β€” both pools below threshold. + manager.registerMesh(entityId: 1, meshSizeBytes: Int(Double(manager.geometryBudget) * 0.80)) XCTAssertFalse(manager.shouldEvict()) - // At/above threshold - manager.registerMesh(entityId: 2, meshSizeBytes: 5 * 1024 * 1024) // 85% + // Push geometry past its 85% threshold. + manager.registerMesh(entityId: 2, meshSizeBytes: Int(Double(manager.geometryBudget) * 0.06)) XCTAssertTrue(manager.shouldEvict()) } @@ -278,8 +281,11 @@ final class MemoryBudgetManagerTests: XCTestCase { } func testShouldNotEvictWhenCombinedMemoryIsBelowThreshold() { - // 40 MB mesh + 40 MB textures = 80% β†’ below 85% high water mark. - manager.registerMesh(entityId: 1, meshSizeBytes: 40 * 1024 * 1024, textureSizeBytes: 40 * 1024 * 1024) + // Geometry at 50% of its pool, texture at 70% of its pool β€” both below 85%. + // With meshBudget = 100 MB: geometryBudget = 60 MB, textureBudget = 40 MB. + let meshAmt = Int(Double(manager.geometryBudget) * 0.50) + let textureAmt = Int(Double(manager.textureBudget) * 0.70) + manager.registerMesh(entityId: 1, meshSizeBytes: meshAmt, textureSizeBytes: textureAmt) XCTAssertFalse(manager.shouldEvict()) } @@ -365,45 +371,50 @@ final class MemoryBudgetManagerTests: XCTestCase { } func testReserveTexture_failsWhenBudgetFull() { - // 95 MB used, only 5 MB free β€” a 10 MB reservation must be rejected. - manager.registerMesh(entityId: 1, meshSizeBytes: 95 * 1024 * 1024) + // Texture pool (40 MB with meshBudget = 100 MB) is nearly full β€” + // a reservation that would overshoot it must be rejected. + // Geometry memory does not affect the texture pool. + let nearFull = Int(Double(manager.textureBudget) * 0.96) + manager.registerMesh(entityId: 1, meshSizeBytes: 1 * 1024 * 1024, textureSizeBytes: nearFull) - XCTAssertFalse(manager.reserveTexture(sizeBytes: 10 * 1024 * 1024), - "reserveTexture should fail when the upgrade would exceed the budget") + XCTAssertFalse(manager.reserveTexture(sizeBytes: Int(Double(manager.textureBudget) * 0.10)), + "reserveTexture should fail when the texture budget would be exceeded") } func testReserveTexture_secondCallSeesReducedHeadroom() { - // Budget: 100 MB. Used: 0 MB. Reserve 60 MB β†’ success. - // Then try to reserve another 60 MB β†’ must fail because only 40 MB remains. - XCTAssertTrue(manager.reserveTexture(sizeBytes: 60 * 1024 * 1024), - "First 60 MB reservation should succeed on an empty budget") - XCTAssertFalse(manager.reserveTexture(sizeBytes: 60 * 1024 * 1024), - "Second 60 MB reservation must fail β€” first reservation reduces apparent headroom") + // Texture budget (40 MB). Reserve 60% β†’ success. Reserve another 60% β†’ must fail. + let sixtyPct = Int(Double(manager.textureBudget) * 0.60) + XCTAssertTrue(manager.reserveTexture(sizeBytes: sixtyPct), + "First 60% texture reservation should succeed on an empty budget") + XCTAssertFalse(manager.reserveTexture(sizeBytes: sixtyPct), + "Second 60% reservation must fail β€” first reservation reduces apparent headroom") - manager.releaseTextureReservation(sizeBytes: 60 * 1024 * 1024) // cleanup + manager.releaseTextureReservation(sizeBytes: sixtyPct) // cleanup } func testReleaseTextureReservation_restoresHeadroom() { - // Reserve 60 MB, verify second 60 MB fails, then release and verify it now succeeds. - XCTAssertTrue(manager.reserveTexture(sizeBytes: 60 * 1024 * 1024)) - XCTAssertFalse(manager.reserveTexture(sizeBytes: 60 * 1024 * 1024), + // Reserve 60% of texture budget, verify a second 60% fails, release, verify it succeeds. + let sixtyPct = Int(Double(manager.textureBudget) * 0.60) + XCTAssertTrue(manager.reserveTexture(sizeBytes: sixtyPct)) + XCTAssertFalse(manager.reserveTexture(sizeBytes: sixtyPct), "Second reservation should fail before release") - manager.releaseTextureReservation(sizeBytes: 60 * 1024 * 1024) + manager.releaseTextureReservation(sizeBytes: sixtyPct) - XCTAssertTrue(manager.reserveTexture(sizeBytes: 60 * 1024 * 1024), + XCTAssertTrue(manager.reserveTexture(sizeBytes: sixtyPct), "After releasing the first reservation the second attempt should succeed") - manager.releaseTextureReservation(sizeBytes: 60 * 1024 * 1024) // cleanup + manager.releaseTextureReservation(sizeBytes: sixtyPct) // cleanup } func testReleaseTextureReservation_doesNotGoBelowZero() { // Releasing more than was reserved must clamp at 0, not underflow. + // If inFlightTextureReservation went negative, canAcceptTexture would over-report headroom. manager.releaseTextureReservation(sizeBytes: 100 * 1024 * 1024) - // If inFlightTextureReservation were negative, canAcceptTexture would over-report headroom. - // Verify the budget check still behaves correctly (no crash, no phantom headroom). - manager.registerMesh(entityId: 1, meshSizeBytes: 99 * 1024 * 1024) - XCTAssertFalse(manager.reserveTexture(sizeBytes: 5 * 1024 * 1024), - "Over-releasing must not create phantom headroom β€” budget should still be nearly full") + // Fill the texture pool to near-capacity and verify the reservation is correctly rejected. + let nearFull = Int(Double(manager.textureBudget) * 0.97) + manager.registerMesh(entityId: 1, meshSizeBytes: 1 * 1024 * 1024, textureSizeBytes: nearFull) + XCTAssertFalse(manager.reserveTexture(sizeBytes: Int(Double(manager.textureBudget) * 0.10)), + "Over-releasing must not create phantom headroom β€” texture budget should still be nearly full") } func testCanAcceptTexture_includesInFlightReservation() { @@ -419,24 +430,28 @@ final class MemoryBudgetManagerTests: XCTestCase { } func testCanAcceptTexture_trueWhenReservationFitsWithinBudget() { - // 50 MB used, 20 MB reserved in-flight (70 MB committed). 20 MB query fits in 30 MB remaining. + // Texture budget = 40 MB (with meshBudget = 100 MB). 20 MB reserved in-flight. + // 20 MB query: (0 used + 20 in-flight + 20 query) = 40 MB ≀ 40 MB budget β†’ fits. + // Geometry memory (50 MB mesh) does not affect the texture pool. manager.registerMesh(entityId: 1, meshSizeBytes: 50 * 1024 * 1024) _ = manager.reserveTexture(sizeBytes: 20 * 1024 * 1024) XCTAssertTrue(manager.canAcceptTexture(sizeBytes: 20 * 1024 * 1024), - "canAcceptTexture should return true when reserved + query still fits in budget") + "canAcceptTexture should return true when reserved + query still fits in texture budget") manager.releaseTextureReservation(sizeBytes: 20 * 1024 * 1024) // cleanup } func testClearResetsInFlightReservation() { - // Reserve some bytes, then clear. After clear a previously-oversized reservation must succeed. - _ = manager.reserveTexture(sizeBytes: 90 * 1024 * 1024) + // Reserve most of the texture budget, then clear. After clear a fresh reservation must succeed. + let nearFull = Int(Double(manager.textureBudget) * 0.90) + _ = manager.reserveTexture(sizeBytes: nearFull) manager.clear() // resets everything including inFlightTextureReservation - // With a fresh state, a 50 MB reservation should succeed even though it was blocked before. - XCTAssertTrue(manager.reserveTexture(sizeBytes: 50 * 1024 * 1024), + // With a fresh state, a 50% reservation should succeed. + let halfBudget = Int(Double(manager.textureBudget) * 0.50) + XCTAssertTrue(manager.reserveTexture(sizeBytes: halfBudget), "clear() must reset inFlightTextureReservation to zero") - manager.releaseTextureReservation(sizeBytes: 50 * 1024 * 1024) // cleanup + manager.releaseTextureReservation(sizeBytes: halfBudget) // cleanup } } diff --git a/docs/Architecture/geometryStreamingSystem.md b/docs/Architecture/geometryStreamingSystem.md index 89bd5325..6f047d6f 100644 --- a/docs/Architecture/geometryStreamingSystem.md +++ b/docs/Architecture/geometryStreamingSystem.md @@ -21,6 +21,8 @@ The system normally does real work every **0.1 seconds** (`updateInterval`). Bet When `lastPendingLoadBacklog > 0` (candidates are queued but all slots are busy), the effective interval drops to `burstTickInterval` (default 16 ms). This prevents a 100 ms stall between slot pickups during active loading. The tick rate returns to 100 ms once the backlog drains. +**OS pressure bypass** β€” if a `pendingPressureRelief` flag is set (fired by the OS pressure callback on a background queue), the throttle check is bypassed entirely for that call. This guarantees eviction runs within one frame (≀ 11 ms at 90 fps) rather than waiting up to 100 ms for the next normal tick. Without this, a `.critical` signal arriving right after a tick would sit unprocessed for the full throttle interval β€” longer than visionOS's kill window. + ### 2. Spatial Query via Octree (line 123) Instead of checking all 500 city entities, it asks the `OctreeSystem`: > "Give me every entity within 500m of the camera." @@ -106,10 +108,10 @@ The engine uses two independent memory pressure signals and responds to them in | Pressure signal | Method | Meaning | |---|---|---| -| Combined (mesh + texture) | `shouldEvict()` | Total GPU allocation β‰₯ 85% of `meshBudget` | -| Geometry only | `shouldEvictGeometry()` | Mesh allocations alone β‰₯ 85% of `meshBudget` | +| Combined | `shouldEvict()` | Geometry pool β‰₯ 85% of `geometryBudget` **OR** texture pool β‰₯ 85% of `textureBudget` | +| Geometry only | `shouldEvictGeometry()` | Mesh allocations alone β‰₯ 85% of `geometryBudget` | -**Why two signals?** `TextureStreamingSystem` upgrades visible textures to higher resolutions after meshes load. Those upgrades increase `totalTextureMemory` in `MemoryBudgetManager`. If the load gate used the combined signal, texture upgrades on already-loaded meshes would silently prevent new mesh loads β€” even when the geometry-only footprint is well within budget. The split ensures texture pressure cannot block geometry loading. +**Why two signals?** `TextureStreamingSystem` upgrades visible textures to higher resolutions after meshes load. Those upgrades increase `totalTextureMemory` in `MemoryBudgetManager`. If the load gate used the combined signal, texture upgrades on already-loaded meshes would silently prevent new mesh loads β€” even when the geometry-only footprint is well within budget. The split pools (`geometryBudget` + `textureBudget`) ensure each domain has an independent ceiling so neither can starve the other. ### Step 1 β€” Texture downgrade relief @@ -139,6 +141,22 @@ if geometry pressure is high: 3. Sorts by value score (far + large = first to go; see value-score eviction in the out-of-core walkthrough) 4. Unloads them one by one until **geometry-only** pressure clears (loop breaks on `shouldEvictGeometry()`, not the combined signal) 5. Skips entities that are both visible and within `visibleEvictionProtectionRadius` (30 m default) +6. Accepts an optional `maxEvictions` cap (default `Int.max`). The OS pressure path passes `16` per call β€” this bounds single-frame work during a burst. Any remaining candidates spill to subsequent ticks. + +The `sizeFactor` in the eviction score is normalized against `geometryBudget` (not the combined budget), so a mesh consuming 80% of the geometry pool scores correctly rather than appearing to consume only ~48% of a combined total. + +### Step 3 β€” OS memory pressure (proactive, out-of-band) + +In addition to the per-tick budget checks above, `MemoryBudgetManager` subscribes to OS memory pressure events via `DispatchSource.makeMemoryPressureSource`: + +| OS signal | Response | `maxEntities` | +|---|---|---| +| `.warning` | Texture shed | 8 | +| `.critical` | Texture shed + double geometry eviction pass (capped at 16 per pass) + CPU heap release | 20 | + +The OS callback fires on a background queue and sets a `pendingPressureRelief` flag on `GeometryStreamingSystem`. The flag is drained at the **start of the next `update()` tick** on the main thread, so all eviction work stays on the same thread as the rest of the streaming system. This prevents the OS from silently escalating to `.critical` and terminating the process β€” on visionOS in particular, the window between `.warning` and process kill can be under a second. + +**CPU heap release on critical pressure** β€” `evictLRU` only frees GPU Metal buffers tracked by `MemoryBudgetManager`. The OS measures total process memory, which includes `ProgressiveAssetLoader.rootAssetRefs` (the live `MDLAsset` tree and all child `CPUMeshEntry` vertex/index buffers). For a 500-building scene this CPU heap can reach hundreds of megabytes. On `.critical`, after the two geometry eviction passes, `GeometryStreamingSystem` calls `ProgressiveAssetLoader.shared.releaseWarmAsset(rootEntityId:)` on every warm root. This frees the CPU heap immediately. The rehydration context (asset URL + loading policy) is retained, so a cold re-stream from disk is transparent when the camera re-approaches. --- @@ -179,3 +197,10 @@ The key design decisions here are: - **Cache ownership** means unloading just clears references, actual GPU memory is reused if the same mesh comes back into range - **Geometry-only load gate** prevents texture upgrades from blocking mesh loads β€” each domain is budgeted independently - **Texture relief before geometry eviction** means a drop in distant texture resolution is always preferred over a missing mesh +- **Split geometry/texture pools** (`geometryBudget` + `textureBudget`) give each domain an independent ceiling and high-water mark β€” a texture-heavy scene cannot crowd out geometry loads and vice versa +- **Runtime device budget probing** β€” `geometryBudget` and `textureBudget` are derived at init from `MTLDevice.recommendedMaxWorkingSetSize` (macOS) or `os_proc_available_memory()` (visionOS/iOS) rather than hardcoded platform defaults; budgets adapt to actual device headroom +- **SceneRootTransform consistency** β€” all distance calculations (GeometryStreamingSystem, LODSystem, inline LOD upload helpers) pass camera position through `SceneRootTransform.shared.effectiveCameraPosition()` so XR physical-head movement and scene-root translations are applied uniformly; raw `cameraComponent.localPosition` is never used directly for distance math +- **Camera sync always runs** β€” `syncStreamingCameraPosition()` executes every frame regardless of the `loading` flag; decoupling it from the loading guard prevents the streaming camera from freezing while an asset load is in flight +- **OS memory pressure subscription** β€” `DispatchSource.makeMemoryPressureSource` fires proactive texture shedding and geometry eviction before the OS escalates to process termination; the response runs on the next `update()` tick to stay single-threaded +- **evictLRU per-call cap** β€” the `maxEvictions` parameter (default `Int.max`) bounds single-frame eviction work; the OS pressure path uses 16 per pass so a `.critical` burst doesn't spike one frame; remaining candidates spill to subsequent ticks +- **CPU heap release on critical pressure** β€” on `.critical`, after geometry eviction, `ProgressiveAssetLoader.releaseWarmAsset()` is called for every warm root, freeing the MDLAsset CPU heap the OS measures; rehydration context survives so cold re-stream from disk is transparent diff --git a/docs/Architecture/outOfCore.md b/docs/Architecture/outOfCore.md index 5042be36..120faddc 100644 --- a/docs/Architecture/outOfCore.md +++ b/docs/Architecture/outOfCore.md @@ -150,16 +150,16 @@ Geometry streaming is selected when any of the following is true: - **Geometry bytes exceed 30% of the platform budget** β€” e.g. 300 MB asset on a 1 GB machine - **Monolithic asset (≀ 2 meshes) AND geometry exceeds 30% of budget** β€” streaming prevents OOM at registration, though the mesh still loads in one step -All thresholds are expressed as **fractions of the live platform budget** (`MemoryBudgetManager.meshBudget`), so they scale correctly across devices: +All thresholds are expressed as **fractions of the live platform budget** (`MemoryBudgetManager.meshBudget`), so they scale correctly across devices. The budget is probed at init from device capabilities rather than using hardcoded platform defaults: -| Device class | `meshBudget` | 30% geometry threshold | +| Platform | Budget source | Formula | |---|---|---| -| macOS | 1 GB | ~300 MB | -| iOS high-end | 512 MB | ~154 MB | -| iOS low-end | 256 MB | ~77 MB | -| visionOS | 512 MB | ~154 MB | +| macOS | `MTLDevice.recommendedMaxWorkingSetSize` | 40% of GPU working set, clamped [512 MB, 3 GB] | +| visionOS / iOS | `os_proc_available_memory()` | 40% of available process memory, clamped [512 MB, 3 GB] | -This means the same 200 MB asset routes to `.eager` on macOS (fits comfortably) but to `.streaming` on low-end iOS (too large to load all at once). The old fixed thresholds (`fileSizeThresholdBytes = 50 MB`, `outOfCoreObjectCountThreshold = 50 objects`) applied the same cutoff regardless of the target device. +The probed total is then split: `geometryBudget = 60%` of total, `textureBudget = 40%` of total. `meshBudget` is a computed alias that returns `geometryBudget + textureBudget` for backward compatibility. + +Because budgets are device-derived, the same 200 MB asset routes to `.eager` on a macOS device with ample GPU headroom but to `.streaming` on a memory-constrained visionOS device. The old fixed thresholds (`fileSizeThresholdBytes = 50 MB`, `outOfCoreObjectCountThreshold = 50 objects`) applied the same cutoff regardless of the target device. The profiler also classifies the asset's dominant memory domain and logs a full breakdown: @@ -321,8 +321,8 @@ The system maintains two independent memory pressure signals: | Signal | Method | Meaning | |---|---|---| -| Combined | `shouldEvict()` | (mesh + texture) β‰₯ 85% of `meshBudget` | -| Geometry only | `shouldEvictGeometry()` | mesh bytes alone β‰₯ 85% of `meshBudget` | +| Combined | `shouldEvict()` | geometry pool β‰₯ 85% of `geometryBudget` **OR** texture pool β‰₯ 85% of `textureBudget` | +| Geometry only | `shouldEvictGeometry()` | mesh bytes alone β‰₯ 85% of `geometryBudget` | The load gate uses **geometry-only pressure** so that texture upgrades on already-loaded entities cannot block new mesh loads. The two signals drive a three-step response before any load starts: @@ -341,6 +341,19 @@ The load gate uses **geometry-only pressure** so that texture upgrades on alread This prevents in-range stubs from uploading simultaneously and pushing GPU memory past the OS kill threshold. The geometry-only gate also prevents the budget-exhaustion/eviction deadlock that occurs on scenes where every entity fits within the streaming radius: texture upgrades no longer consume geometry headroom, so all stubs can load regardless of how much texture memory is in use. +### OS memory pressure (proactive, out-of-band) + +In addition to the per-tick budget checks above, `MemoryBudgetManager` subscribes to OS memory pressure events via `DispatchSource.makeMemoryPressureSource`. The OS callback fires on a background queue and sets a `pendingPressureRelief` flag on `GeometryStreamingSystem`. The flag is drained at the **start of the next `update()` tick** on the main thread so eviction work stays single-threaded: + +| OS signal | Response | +|---|---| +| `.warning` | `shedTextureMemory(maxEntities: 8)` + one `evictLRU` pass (capped at 16 evictions) | +| `.critical` | `shedTextureMemory(maxEntities: 20)` + two `evictLRU` passes (16 each) + CPU heap release | + +On visionOS the window between `.warning` and process termination can be under a second. The proactive response prevents the OS from escalating to `.critical` and killing the process. + +**CPU heap release** β€” on `.critical`, after both geometry eviction passes, `ProgressiveAssetLoader.releaseWarmAsset()` is called for every warm root. This frees the `MDLAsset` tree and all `CPUMeshEntry` vertex/index buffers from the CPU heap β€” the memory the OS actually measures, not just GPU Metal allocations. The rehydration context (URL + policy) is retained, so a cold re-stream from disk is transparent when the camera re-approaches. + ### Adaptive tick rate By default `update()` runs every `updateInterval` (0.1 s). When `lastPendingLoadBacklog > 0` β€” meaning candidates were queued but not dispatched due to the concurrency cap β€” the tick interval drops to `burstTickInterval` (default 16 ms). This prevents a 100 ms cadence stall while work is actively waiting for a slot. @@ -410,7 +423,7 @@ if let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId), } ``` -`canAcceptMesh` checks only `totalMeshMemory + sizeBytes ≀ meshBudget` β€” texture memory is excluded. This ensures that a large batch of texture upgrades on visible entities cannot prevent a nearby stub from loading its geometry. +`canAcceptMesh` checks only `totalMeshMemory + sizeBytes ≀ geometryBudget` β€” texture memory is excluded. This ensures that a large batch of texture upgrades on visible entities cannot prevent a nearby stub from loading its geometry. `estimatedGPUBytes` (stored in `CPUMeshEntry` at stub-registration time) lets this check run without any GPU work or disk I/O. @@ -440,12 +453,14 @@ For each nearby entity: ``` distanceFactor = min(1.0, distance / maxQueryRadius) -sizeFactor = min(1.0, meshBytes / meshBudget) +sizeFactor = min(1.0, meshBytes / geometryBudget) score = evictionDistanceWeight Γ— distanceFactor + evictionSizeWeight Γ— sizeFactor ``` Highest score is evicted first β€” far, large meshes go before near, small ones. `lastVisibleFrame` is the tiebreaker for equal scores. This protects nearby small meshes (high camera-coverage value) while freeing the largest far meshes first. +`evictLRU` accepts a `maxEvictions: Int` parameter (default `Int.max`). The OS pressure path passes `16` per call to bound single-frame work during a burst; remaining candidates spill to subsequent ticks. Normal per-load-gate calls use the default (unbounded, exits once geometry pressure clears). + #### Distance-aware visibility guard The eviction loop also applies a distance-aware guard to visible entities: @@ -502,7 +517,7 @@ if let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId) { 1. `makeMeshesFromCPUBuffers` β€” copies MDLMesh vertex/index data from CPU heap to Metal-backed buffers 2. `registerRenderComponent` β€” entity gets a `RenderComponent`, becomes visible -3. `MemoryBudgetManager.registerMesh` β€” registers the Metal allocation so `shouldEvict()` sees it +3. `MemoryBudgetManager.registerMesh` β€” registers the Metal allocation so `shouldEvict()` sees it; `textureSizeBytes` is `0`. Texture memory is tracked separately by `TextureStreamingSystem` once streaming completes; pre-estimating at upload time would permanently over-fill the texture pool because the estimate is never replaced (streaming only updates its own tracking, not the mesh registration record). 4. CPU data is **not** cleared β€” the `cpuMeshRegistry` entry stays so the next eviction+reload cycle re-uploads from RAM, not disk ### Memory model at steady state @@ -672,7 +687,9 @@ ProgressiveAssetLoader.shared.releaseWarmAsset(rootEntityId: rootId) | Property | Default | Effect | |----------|---------|--------| -| `MemoryBudgetManager.meshBudget` | device-set | GPU memory ceiling; all `AssetProfiler` thresholds are expressed as fractions of this value | +| `MemoryBudgetManager.meshBudget` | device-set (computed alias) | Read: `geometryBudget + textureBudget`. Write: splits the assigned value 60/40 between the two pools. Preserved for backward compatibility | +| `MemoryBudgetManager.geometryBudget` | 60% of probed total | Independent ceiling for mesh GPU memory; `canAcceptMesh`, `shouldEvictGeometry`, and `evictLRU` scoring all use this value | +| `MemoryBudgetManager.textureBudget` | 40% of probed total | Independent ceiling for texture GPU memory; `canAcceptTexture` and texture-pool pressure signals use this value | | `GeometryStreamingSystem.maxConcurrentLoads` | 3 | Total concurrent CPUβ†’Metal uploads across both bands | | `GeometryStreamingSystem.nearBandFraction` | 0.33 | Fraction of `streamingRadius` defining the near band; near-band loads are serialized | | `GeometryStreamingSystem.nearBandMaxConcurrentLoads` | 1 | Max in-flight loads in the near band; 1 guarantees distance-ordered appearance | diff --git a/docs/Architecture/textureStreamingSystem.md b/docs/Architecture/textureStreamingSystem.md index bcfa78d6..bb326c6d 100644 --- a/docs/Architecture/textureStreamingSystem.md +++ b/docs/Architecture/textureStreamingSystem.md @@ -264,8 +264,10 @@ This is called by `GeometryStreamingSystem` β€” not on a timer, but reactively w |---|---|---| | `GeometryStreamingSystem.update()` | 4 | Combined pressure high, geometry pressure low β€” texture relief only, no geometry eviction | | `GeometryStreamingSystem.update()` | 8 | Geometry pressure also high β€” shed texture first, then evict geometry | +| OS `.warning` pressure callback | 8 | `MemoryBudgetManager.onMemoryPressureWarning` fires β€” proactive shed before OS escalates | +| OS `.critical` pressure callback | 20 | `MemoryBudgetManager.onMemoryPressureCritical` fires β€” aggressive shed + double geometry eviction pass (16 evictions each) + CPU heap release via `ProgressiveAssetLoader.releaseWarmAsset()` | -The larger batch size (8) when geometry is also under pressure reflects that more aggressive texture shedding is needed before the costlier geometry eviction path runs. +The larger batch size (8) when geometry is also under pressure reflects that more aggressive texture shedding is needed before the costlier geometry eviction path runs. The OS pressure rows bypass the normal per-tick budget check entirely β€” they fire out-of-band whenever the OS signals memory pressure, and the actual shedding runs on the next `GeometryStreamingSystem.update()` tick (deferred via a flag to stay on the main thread). ---