diff --git a/Sources/UntoldEngine/Mesh/Mesh.swift b/Sources/UntoldEngine/Mesh/Mesh.swift index a4d5db62..fdb9f176 100644 --- a/Sources/UntoldEngine/Mesh/Mesh.swift +++ b/Sources/UntoldEngine/Mesh/Mesh.swift @@ -38,89 +38,26 @@ public enum CoordinateSystemConversion: Sendable { case none // Use transforms as-is from USD } -func orientationTransformForAsset(_ asset: MDLAsset, conversion: CoordinateSystemConversion) -> simd_float4x4 { - let zUpToYUpMatrix: simd_float4x4 = { - var m = matrix_identity_float4x4 - // Column 0: image of (1,0,0) -> X stays X - m.columns.0 = simd_float4(1, 0, 0, 0) - // Column 1: image of (0,1,0) -> Y becomes -Z - m.columns.1 = simd_float4(0, 0, -1, 0) - // Column 2: image of (0,0,1) -> Z becomes Y - m.columns.2 = simd_float4(0, 1, 0, 0) - // Column 3: translation - m.columns.3 = simd_float4(0, 0, 0, 1) - return m - }() - - switch conversion { - case .none: - return matrix_identity_float4x4 - - case .forceZUpToYUp: - return zUpToYUpMatrix - - case .autoDetect: - let sourceUp = simd_normalize(asset.upAxis) - let yUp = simd_float3(0, 1, 0) - let zUp = simd_float3(0, 0, 1) - - // Already Y-up → no conversion needed - if sourceUp.isApproximately(yUp) { - return matrix_identity_float4x4 - } - - // Z-up (Blender default) → convert to Y-up - if sourceUp.isApproximately(zUp) { - return zUpToYUpMatrix - } - - // Unknown up axis → no conversion (could log warning here) - return matrix_identity_float4x4 - } -} - -func composedWorldTransform(for object: MDLObject) -> simd_float4x4 { - var result = simd_float4x4.identity - var current: MDLObject? = object - - while let node = current { - let local = node.transform?.matrix ?? .identity - result = simd_mul(local, result) - current = node.parent - } - - return result -} - public struct Mesh { public let metalKitMesh: MTKMesh public var submeshes: [SubMesh] = [] - public var modelMDLMesh: MDLMesh public var localSpace: simd_float4x4 = .identity public var worldSpace: simd_float4x4 = .identity var assetName: String var boundingBox: (min: simd_float3, max: simd_float3) var skin: Skin? + /// Create a Mesh from an in-memory MDLMesh (used by the native .untold upload path). + /// + /// `makeMesh(from: RuntimeMeshPrimitive)` builds an in-memory MDLMesh from decoded + /// vertex/index data, then calls this init to create the MTKMesh GPU buffers. + /// localSpace and worldSpace are overwritten by the caller from primitive data. init?(modelIOMesh: MDLMesh, vertexDescriptor: MDLVertexDescriptor, textureLoader: TextureLoader, device: MTLDevice, flip _: Bool) { - modelMDLMesh = modelIOMesh - - // Keep mesh-local transform for ECS local-space semantics. - let meshTransform = modelIOMesh.transform?.matrix ?? .identity - - // Store local space transform - localSpace = meshTransform - - // Compose full ModelIO ancestry chain (root -> ... -> mesh) for world space. - worldSpace = composedWorldTransform(for: modelIOMesh) - - // Set asset name from parent, or use the mesh's own name if no parent + localSpace = modelIOMesh.transform?.matrix ?? .identity + worldSpace = localSpace assetName = modelIOMesh.parent?.name ?? modelIOMesh.name - - // Set bounding box dimensions boundingBox = (min: modelIOMesh.boundingBox.minBounds, max: modelIOMesh.boundingBox.maxBounds) - // Create tangents if the mesh has texture coordinates if hasTextureCoordinates(mesh: modelIOMesh) { modelIOMesh.addOrthTanBasis( forTextureCoordinateAttributeNamed: MDLVertexAttributeTextureCoordinate, @@ -128,12 +65,8 @@ public struct Mesh { tangentAttributeNamed: MDLVertexAttributeTangent ) } - - // Apply vertex descriptor for Metal layout compatibility modelIOMesh.vertexDescriptor = vertexDescriptor - // Create MetalKit mesh - modelIOMesh.vertexDescriptor = vertexDescriptor var localMetalKitMesh: MTKMesh do { localMetalKitMesh = try MTKMesh(mesh: modelIOMesh, device: device) @@ -143,8 +76,7 @@ public struct Mesh { } metalKitMesh = localMetalKitMesh - // Process submeshes locally before assigning - let processedSubmeshes: [SubMesh] = modelIOMesh.submeshes?.enumerated().compactMap { index, element in + submeshes = modelIOMesh.submeshes?.enumerated().compactMap { index, element in guard let mdlSubmesh = element as? MDLSubmesh else { return nil } guard index < localMetalKitMesh.submeshes.count else { return nil } return SubMesh( @@ -153,8 +85,6 @@ public struct Mesh { textureLoader: textureLoader ) } ?? [] - - submeshes = processedSubmeshes } mutating func cleanUp() { @@ -169,742 +99,21 @@ public struct Mesh { self } - /// Load meshes from a file URL - static func loadMeshes(url: URL, vertexDescriptor: MDLVertexDescriptor, device: MTLDevice, flip: Bool, coordinateConversion: CoordinateSystemConversion = .autoDetect) -> [Mesh] { - let bufferAllocator = MTKMeshBufferAllocator(device: device) - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: bufferAllocator) - - // Apply coordinate system conversion if needed (e.g., Blender Z-up to engine Y-up) - let orientationTransform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if orientationTransform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform(matrix: simd_mul(orientationTransform, object.transform?.matrix ?? .identity)) - } - } - - asset.loadTextures() - - let textureLoader = TextureLoader(device: device) - - let meshes = asset.childObjects(of: MDLObject.self).flatMap { - makeMeshes(object: $0, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip) - } - - textureLoader.logSummary() - - if !meshes.isEmpty { - return meshes - } - - // ---- Fallback path: fabricate a safe default mesh ---- - - return makeDefaultMesh() - } - - /// Load meshes asynchronously from a file URL - static func loadMeshesAsync( - url: URL, - vertexDescriptor: MDLVertexDescriptor, - device: MTLDevice, - flip: Bool, - coordinateConversion: CoordinateSystemConversion = .autoDetect, - progressHandler: (@Sendable (Int, Int) -> Void)? = nil - ) async -> [Mesh] { - // Perform heavy I/O work on background thread - let asset = await Task.detached { () -> MDLAsset? in - let bufferAllocator = MTKMeshBufferAllocator(device: device) - - // Validate file exists and get size - guard FileManager.default.fileExists(atPath: url.path) else { - handleError(.assetFileNotFound, url.path) - return nil - } - - do { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - if let fileSize = attributes[.size] as? UInt64 { - let fileSizeMB = Double(fileSize) / (1024 * 1024) - Logger.log(message: "Loading asset: \(url.lastPathComponent) (\(String(format: "%.2f", fileSizeMB)) MB)") - - // Warn for very large files (> 500 MB) - if fileSizeMB > 500 { - Logger.logWarning(message: "Large asset detected (\(String(format: "%.2f", fileSizeMB)) MB). Loading may take time and consume significant memory.") - } - } - } catch { - Logger.logWarning(message: "Could not determine file size: \(error.localizedDescription)") - } - - // Wrap asset creation in error handling to catch parsing/corruption issues - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: bufferAllocator) - - // Apply coordinate system conversion - let transform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if transform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform(matrix: simd_mul(transform, object.transform?.matrix ?? .identity)) - } - } - - // Load textures on background thread - asset.loadTextures() - - return asset - }.value - - // Handle asset loading failure - guard let asset else { - handleError(.assetLoadFailed, url.lastPathComponent) - return makeDefaultMesh() - } - - // Create Metal resources off-main to maximize parallelism across async loads. - let textureLoader = TextureLoader(device: device) - let objects = asset.childObjects(of: MDLObject.self) - - // Count total meshes - var totalMeshCount = 0 - for object in objects { - if let _ = object as? MDLMesh { - totalMeshCount += 1 - } else if object.conforms(to: MDLObjectContainerComponent.self) { - func countMeshes(_ obj: MDLObject) -> Int { - var count = 0 - if let _ = obj as? MDLMesh { count += 1 } - for child in obj.children.objects { - count += countMeshes(child) - } - return count - } - totalMeshCount += countMeshes(object) - } - } - - // Check against MAX_ENTITIES - let willEnforceLimit = totalMeshCount > MAX_ENTITIES - if willEnforceLimit { - Logger.logWarning(message: "⚠️ Asset contains \(totalMeshCount) meshes but MAX_ENTITIES is set to \(MAX_ENTITIES)") - Logger.logWarning(message: "Only the first \(MAX_ENTITIES) meshes will be loaded to prevent crashes.") - Logger.logWarning(message: "To load all meshes, increase MAX_ENTITIES in Globals.swift to at least \(totalMeshCount)") - } - - var allMeshes: [Mesh] = [] - allMeshes.reserveCapacity(min(totalMeshCount, MAX_ENTITIES)) - var processedMeshes = 0 - let totalForProgress = min(totalMeshCount, MAX_ENTITIES) - let progressStride = totalForProgress > 200 ? 25 : 1 - var lastReportedProgress = 0 - - for object in objects { - // Enforce MAX_ENTITIES limit - if processedMeshes >= MAX_ENTITIES { - Logger.logWarning(message: "🛑 Reached MAX_ENTITIES limit (\(MAX_ENTITIES)). Stopped loading.") - Logger.logWarning(message: "Loaded \(processedMeshes)/\(totalMeshCount) meshes.") - break - } - - let meshes = makeMeshes(object: object, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip) - allMeshes.append(contentsOf: meshes) - processedMeshes += meshes.count - - if let progressHandler { - let shouldReport = processedMeshes == totalForProgress || (processedMeshes - lastReportedProgress) >= progressStride - if shouldReport { - lastReportedProgress = processedMeshes - progressHandler(processedMeshes, totalForProgress) - } - } - } - - Logger.log(message: "✅ Loaded \(processedMeshes)/\(totalMeshCount) meshes" + (willEnforceLimit ? " (MAX_ENTITIES limit enforced)" : "")) - textureLoader.logSummary() - - if !allMeshes.isEmpty { - return allMeshes - } - - // Fallback to default mesh - return makeDefaultMesh() - } - - static func loadSceneMeshes(url: URL, vertexDescriptor: MDLVertexDescriptor, device: MTLDevice, coordinateConversion: CoordinateSystemConversion = .autoDetect) -> [[Mesh]] { - let bufferAllocator = MTKMeshBufferAllocator(device: device) - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: bufferAllocator) - - // Apply coordinate system conversion if needed (e.g., Blender Z-up to engine Y-up) - let orientationTransform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if orientationTransform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform(matrix: simd_mul(orientationTransform, object.transform?.matrix ?? .identity)) - } - } - - asset.loadTextures() - - let textureLoader = TextureLoader(device: device) - - let meshGroups: [[Mesh]] = asset.childObjects(of: MDLObject.self).map { - makeMeshes(object: $0, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: true) - } - - textureLoader.logSummary() - - // If the scene had no objects (or everything failed), return a single fallback group - if meshGroups.isEmpty || meshGroups.allSatisfy(\.isEmpty) { - Logger.logWarning(message: "Scene contained no usable meshes: \(url.lastPathComponent). Returning a single default fallback mesh.") - return [makeDefaultMesh()] - } - - return meshGroups - } - - /// Load scene meshes asynchronously from a file URL - static func loadSceneMeshesAsync( - url: URL, - vertexDescriptor: MDLVertexDescriptor, - device: MTLDevice, - coordinateConversion: CoordinateSystemConversion = .autoDetect, - progressHandler: (@Sendable (Int, Int) -> Void)? = nil - ) async -> [[Mesh]] { - // Perform heavy I/O work on background thread - let asset = await Task.detached { () -> MDLAsset? in - let bufferAllocator = MTKMeshBufferAllocator(device: device) - - // Validate file exists and get size - guard FileManager.default.fileExists(atPath: url.path) else { - handleError(.assetFileNotFound, url.path) - return nil - } - - do { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - if let fileSize = attributes[.size] as? UInt64 { - let fileSizeMB = Double(fileSize) / (1024 * 1024) - Logger.log(message: "Loading scene asset: \(url.lastPathComponent) (\(String(format: "%.2f", fileSizeMB)) MB)") - - // Warn for very large files (> 500 MB) - if fileSizeMB > 500 { - Logger.logWarning(message: "Large scene asset detected (\(String(format: "%.2f", fileSizeMB)) MB). Loading may take time and consume significant memory.") - } - } - } catch { - Logger.logWarning(message: "Could not determine file size: \(error.localizedDescription)") - } - - // Wrap asset creation in error handling to catch parsing/corruption issues - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: bufferAllocator) - - // Apply coordinate system conversion - let transform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if transform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform(matrix: simd_mul(transform, object.transform?.matrix ?? .identity)) - } - } - - // Load textures on background thread - asset.loadTextures() - - return asset - }.value - - // Handle asset loading failure - guard let asset else { - handleError(.assetLoadFailed, url.lastPathComponent) - return [makeDefaultMesh()] - } - - // Create Metal resources off-main to maximize parallelism across async loads. - let textureLoader = TextureLoader(device: device) - let objects = asset.childObjects(of: MDLObject.self) - - // First pass: count total meshes for accurate progress - var totalMeshCount = 0 - for object in objects { - if let _ = object as? MDLMesh { - totalMeshCount += 1 - } else if object.conforms(to: MDLObjectContainerComponent.self) { - func countMeshes(_ obj: MDLObject) -> Int { - var count = 0 - if let _ = obj as? MDLMesh { count += 1 } - for child in obj.children.objects { - count += countMeshes(child) - } - return count - } - totalMeshCount += countMeshes(object) - } - } - - // Check against MAX_ENTITIES hard limit - let willEnforceLimit = totalMeshCount > MAX_ENTITIES - let meshesToLoad = min(totalMeshCount, MAX_ENTITIES) - - if willEnforceLimit { - Logger.logWarning(message: "⚠️ Scene contains \(totalMeshCount) meshes but MAX_ENTITIES is set to \(MAX_ENTITIES)") - Logger.logWarning(message: "Only the first \(MAX_ENTITIES) meshes will be loaded to prevent crashes.") - Logger.logWarning(message: "To load all meshes, increase MAX_ENTITIES in Globals.swift to at least \(totalMeshCount)") - Logger.logWarning(message: "Note: Increasing MAX_ENTITIES will use more memory (~\(totalMeshCount * 2)KB additional).") - } else { - // Soft warnings for large scenes even within limits - let maxRecommendedMeshes = MAX_ENTITIES / 2 - let criticalMeshCount = Int(Double(MAX_ENTITIES) * 0.8) - - if totalMeshCount > criticalMeshCount { - Logger.logWarning(message: "Scene contains \(totalMeshCount) meshes (approaching MAX_ENTITIES limit of \(MAX_ENTITIES))") - Logger.logWarning(message: "Loading may take time and consume significant memory.") - } else if totalMeshCount > maxRecommendedMeshes { - Logger.log(message: "Scene contains \(totalMeshCount) meshes (within MAX_ENTITIES limit of \(MAX_ENTITIES))") - } - } - - var meshGroups: [[Mesh]] = [] - var processedMeshes = 0 - var failedMeshes = 0 - - let enableVerboseLogging = totalMeshCount > 1000 // Only log progress for very large scenes - let progressStride = meshesToLoad > 200 ? 25 : 1 - var lastReportedProgress = 0 - - for (index, object) in objects.enumerated() { - // ENFORCE MAX_ENTITIES LIMIT - stop loading if we've hit the limit - if processedMeshes >= MAX_ENTITIES { - let remainingMeshes = totalMeshCount - processedMeshes - Logger.logWarning(message: "🛑 Reached MAX_ENTITIES limit (\(MAX_ENTITIES)). Stopped loading.") - Logger.logWarning(message: "Loaded \(processedMeshes)/\(totalMeshCount) meshes successfully.") - Logger.logWarning(message: "\(remainingMeshes) meshes were not loaded.") - Logger.logWarning(message: "Increase MAX_ENTITIES in Globals.swift to \(totalMeshCount) to load the complete scene.") - break // Exit the loop - we've hit the entity limit - } - - // Log progress every 200 objects for very large files (reduce spam) - if enableVerboseLogging, index % 200 == 0, index > 0 { - Logger.log(message: "Processing object \(index)/\(objects.count) (\(Int((Double(index) / Double(objects.count)) * 100))% complete)...") - } - - // Wrap mesh creation to catch crashes from memory pressure - let meshes = makeMeshes(object: object, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: true) - - if meshes.isEmpty, object is MDLMesh { - failedMeshes += 1 - if failedMeshes <= 10 { // Only log first 10 failures to avoid spam - Logger.logWarning(message: "Failed to create mesh from object at index \(index). Skipping.") - } - } - - meshGroups.append(meshes) - processedMeshes += meshes.count - - if let progressHandler { - let shouldReport = processedMeshes == meshesToLoad || (processedMeshes - lastReportedProgress) >= progressStride - if shouldReport { - lastReportedProgress = processedMeshes - progressHandler(processedMeshes, min(meshesToLoad, totalMeshCount)) - } - } - } - - // Log completion statistics - let successfulMeshes = processedMeshes - let wasLimitEnforced = totalMeshCount > MAX_ENTITIES && processedMeshes >= MAX_ENTITIES - - if wasLimitEnforced { - Logger.log(message: "✅ Partial mesh loading complete: \(successfulMeshes)/\(totalMeshCount) meshes loaded (MAX_ENTITIES limit enforced)") - } else { - Logger.log(message: "✅ Mesh loading complete: \(successfulMeshes)/\(totalMeshCount) meshes loaded successfully") - } - - if failedMeshes > 0 { - Logger.logWarning(message: "\(failedMeshes) meshes failed to load and were skipped") - } - - textureLoader.logSummary() - - // If the scene had no objects (or everything failed), return a single fallback group - if meshGroups.isEmpty || meshGroups.allSatisfy(\.isEmpty) { - Logger.logWarning(message: "Scene contained no usable meshes: \(url.lastPathComponent). Returning a single default fallback mesh.") - return [makeDefaultMesh()] - } - - return meshGroups - } - - // MARK: - Progressive Loading Support - - /// Holds MDLAsset and its top-level objects after a CPU-only parse. - /// No Metal buffers are allocated yet — MTKMesh creation happens per-batch in ProgressiveAssetLoader. - struct ProgressiveAssetData: @unchecked Sendable { - let asset: MDLAsset // Retained so MDLMesh objects stay valid during batch processing - let topLevelObjects: [MDLObject] - let textureLoader: TextureLoader - let totalObjectCount: Int - } - - /// Parse a USD/USDZ asset without allocating GPU buffers. - /// - /// Passes `nil` as the `bufferAllocator` so ModelIO stores vertex/index data in CPU - /// heap memory instead of Metal shared buffers. This eliminates the all-at-once GPU - /// memory spike that occurs when loading very large scenes. - /// - /// Call `Mesh.makeMeshes(object:...)` on the returned objects in small batches to - /// create Metal resources gradually over multiple frames. - static func parseAssetAsync( - url: URL, - vertexDescriptor: MDLVertexDescriptor, - device: MTLDevice, - coordinateConversion: CoordinateSystemConversion = .autoDetect - ) async -> ProgressiveAssetData? { - await Task.detached { () -> ProgressiveAssetData? in - guard FileManager.default.fileExists(atPath: url.path) else { - handleError(.assetFileNotFound, url.path) - return nil - } - - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? UInt64 - { - let mb = Double(size) / (1024 * 1024) - Logger.log( - message: "[ProgressiveLoader] Parsing '\(url.lastPathComponent)' (\(String(format: "%.1f", mb)) MB) — CPU allocator, no GPU pre-allocation", - category: LogCategory.assetLoader.rawValue - ) - } - - // MDLMeshBufferDataAllocator stores all vertex/index data in CPU heap memory - // (NSData-backed MDLMeshBufferData objects). No Metal buffers are allocated yet. - // When MTKMesh(mesh:device:) is called per-batch it copies each mesh's Data - // into a new MTLBuffer — this is what enables staged GPU upload. - // - // nil would not work here: passing nil causes ModelIO to skip buffer - // materialisation entirely, leaving empty buffers that MTKMesh cannot copy - // (MTKModelErrorNoMTLBuffer / error 0). - let cpuAllocator = MDLMeshBufferDataAllocator() - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: cpuAllocator) - - let transform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if transform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform( - matrix: simd_mul(transform, object.transform?.matrix ?? .identity) - ) - } - } - - // loadTextures() is intentionally NOT called here. - // - // Texture loading is deferred to first-upload time and driven by the - // asset's `TextureResidencyPolicy` (set by AssetProfiler.classifyPolicy): - // - // - `.eager` policy: `GeometryStreamingSystem.uploadFromCPUEntry` calls - // `ProgressiveAssetLoader.ensureTexturesLoaded` before makeMeshesFromCPUBuffers. - // This calls asset.loadTextures() exactly once, decompressing all embedded - // textures into CPU RAM just before the first mesh upload — avoiding a RAM spike - // at parse time when no GPU work has started yet. - // - // - `.streaming` policy: ensureTexturesLoaded is skipped. TextureLoader's lazy - // hydration path (texelDataWithTopLeftOrigin(atMipLevel:0, create:true)) loads - // each texture individually on demand during Material.init, so the full-asset - // decompression spike never occurs. Source references (baseColorMDLTexture / - // baseColorURL) are populated in the Material so TextureStreamingSystem can - // upgrade resolution tiers by camera distance later. - - let textureLoader = TextureLoader(device: device) - // childObjects(of: MDLMesh.self) returns only the actual geometry leaves at - // every level of the hierarchy — no intermediate transform groups. - // Using MDLObject.self instead would return ALL nodes (groups + meshes), - // causing tick() to process the root group first (which recursively uploads - // the entire asset in one shot) and then fail on the individual mesh items - // whose CPU buffers were already cleared. - let objects = Array(asset.childObjects(of: MDLMesh.self)) - - Logger.log( - message: "[ProgressiveLoader] '\(url.lastPathComponent)': \(objects.count) mesh leaves queued for progressive registration", - category: LogCategory.assetLoader.rawValue - ) - - return ProgressiveAssetData( - asset: asset, - topLevelObjects: objects, - textureLoader: textureLoader, - totalObjectCount: objects.count - ) - }.value - } - - /// Copy all vertex/index buffers from a CPU-backed MDLMesh (MDLMeshBufferDataAllocator) - /// to fresh Metal-backed buffers using a per-mesh MTKMeshBufferAllocator. - /// - /// `MTKMesh(mesh:device:)` requires MTKMeshBuffer-backed buffers and cannot copy from - /// MDLMeshBufferData (CPU-heap) buffers — this is what causes MTKModelErrorNoMTLBuffer. - /// This function performs the explicit CPU→GPU copy so each batch item only allocates - /// Metal memory for its own mesh data, keeping GPU allocation incremental. - private static func copyBuffersToMetal( - _ mdlMesh: MDLMesh, - device: MTLDevice - ) -> MDLMesh? { - let mtkAllocator = MTKMeshBufferAllocator(device: device) - - // Copy vertex buffers - let newVertexBuffers: [MDLMeshBuffer] = mdlMesh.vertexBuffers.map { srcBuf in - let dst = mtkAllocator.newBuffer(srcBuf.length, type: .vertex) - let srcMap = srcBuf.map() - let dstMap = dst.map() - memcpy(dstMap.bytes, srcMap.bytes, srcBuf.length) - return dst - } - - // Copy index buffers and rebuild submeshes - var newSubmeshes: [MDLSubmesh] = [] - for case let sub as MDLSubmesh in mdlMesh.submeshes ?? NSMutableArray() { - let srcIdx = sub.indexBuffer - let dstIdx = mtkAllocator.newBuffer(srcIdx.length, type: .index) - let srcMap = srcIdx.map() - let dstMap = dstIdx.map() - memcpy(dstMap.bytes, srcMap.bytes, srcIdx.length) - let newSub = MDLSubmesh( - name: sub.name, - indexBuffer: dstIdx, - indexCount: sub.indexCount, - indexType: sub.indexType, - geometryType: sub.geometryType, - material: sub.material - ) - newSubmeshes.append(newSub) - } - - let mtkMesh = MDLMesh( - vertexBuffers: newVertexBuffers, - vertexCount: mdlMesh.vertexCount, - descriptor: mdlMesh.vertexDescriptor, - submeshes: newSubmeshes - ) - // Copy name and local transform from original — no world-transform baking. - // localSpace and worldSpace are assigned directly after Mesh.init to avoid - // the MDLTransform(matrix:) decompose/recompose round-trip that can corrupt scale. - mtkMesh.name = mdlMesh.parent?.name ?? mdlMesh.name - mtkMesh.transform = mdlMesh.transform - return mtkMesh - } - - /// Progressive-loading variant of makeMeshes for CPU-parsed assets. - /// - /// For each MDLMesh in the object hierarchy: - /// 1. Computes tangent basis on CPU (addOrthTanBasis) — safe with MDLMeshBufferDataAllocator. - /// 2. Copies vertex/index buffers from CPU heap to new Metal-backed (MTK) buffers. - /// 3. Creates Mesh via normal Mesh.init — MTKMesh creation succeeds on MTK-backed buffers. - /// - /// Called from ProgressiveAssetLoader.tick() instead of makeMeshes() because - /// MTKMesh(mesh:device:) requires MTKMeshBuffer-backed buffers and produces - /// MTKModelErrorNoMTLBuffer (error 0) when given MDLMeshBufferData CPU buffers. - static func makeMeshesFromCPUBuffers( - object: MDLObject, - vertexDescriptor: MDLVertexDescriptor, - textureLoader: TextureLoader, - device: MTLDevice, - flip: Bool - ) -> [Mesh] { - var meshes = [Mesh]() - - if let mdlMesh = object as? MDLMesh { - // Step 1: compute tangent basis in CPU memory - if hasTextureCoordinates(mesh: mdlMesh) { - mdlMesh.addOrthTanBasis( - forTextureCoordinateAttributeNamed: MDLVertexAttributeTextureCoordinate, - normalAttributeNamed: MDLVertexAttributeNormal, - tangentAttributeNamed: MDLVertexAttributeTangent - ) - } - - // Step 2: capture transforms directly from original (has full parent chain). - // We read these now and assign them directly after Mesh.init to avoid the - // MDLTransform(matrix:) decompose/recompose round-trip that can corrupt scale. - let localTransform = mdlMesh.transform?.matrix ?? .identity - let worldTransform = composedWorldTransform(for: mdlMesh) - let assetName = mdlMesh.parent?.name ?? mdlMesh.name - - // Step 3: copy CPU buffers to Metal-backed buffers - if let mtkBacked = copyBuffersToMetal(mdlMesh, device: device) { - // Step 4: Mesh.init — addOrthTanBasis is a no-op (done above), - // vertexDescriptor= works on MTK-backed buffers, MTKMesh succeeds. - if var mesh = Mesh(modelIOMesh: mtkBacked, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip) { - // Directly assign the correct transforms from the original MDLMesh. - // This matches exactly what Mesh.init would produce with the original - // (MTKMeshBufferAllocator-backed) mesh that has a full parent chain. - mesh.localSpace = localTransform - mesh.worldSpace = worldTransform - meshes.append(mesh) - } else { - handleError(.meshCreationFailed, "CPU to Metal buffer copy failed", assetName) - } - } - } - - if object.conforms(to: MDLObjectContainerComponent.self) { - for child in object.children.objects { - meshes.append(contentsOf: makeMeshesFromCPUBuffers( - object: child, - vertexDescriptor: vertexDescriptor, - textureLoader: textureLoader, - device: device, - flip: flip - )) - } - } - - return meshes - } - - /// Free CPU-heap vertex buffers for every MDLMesh in the hierarchy. - /// - /// Called immediately after `makeMeshesFromCPUBuffers` so that `MDLMeshBufferData` - /// objects (CPU heap, potentially hundreds of MB for large USDZ files) are released - /// as soon as their data has been copied to Metal. Without this, the full file remains - /// in CPU RAM for the entire duration of progressive loading, doubling peak memory and - /// causing OOM kills on Vision Pro. - /// - /// Index buffers (`MDLSubmesh.indexBuffer`) are read-only in ModelIO and cannot be - /// cleared here, but vertex data is the dominant cost so this still reduces peak - /// CPU memory by ~80–90%. - /// - /// Note: after this call the object's vertex buffers are empty. Do not call - /// `makeMeshesFromCPUBuffers` on the same object again — use the Metal-backed - /// `Mesh` structs that were returned by the previous call instead. - static func clearCPUBuffers(object: MDLObject) { - if let mesh = object as? MDLMesh { - mesh.vertexBuffers = [] - } - if object.conforms(to: MDLObjectContainerComponent.self) { - for child in object.children.objects { - clearCPUBuffers(object: child) - } - } - } - - static func loadMeshWithName(name: String, url: URL, vertexDescriptor: MDLVertexDescriptor, device: MTLDevice, coordinateConversion: CoordinateSystemConversion = .autoDetect) -> [Mesh] { - let bufferAllocator = MTKMeshBufferAllocator(device: device) - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: bufferAllocator) - - // Apply coordinate system conversion if needed (e.g., Blender Z-up to engine Y-up) - let orientationTransform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if orientationTransform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform(matrix: simd_mul(orientationTransform, object.transform?.matrix ?? .identity)) - } - } - - asset.loadTextures() - - let textureLoader = TextureLoader(device: device) - - let topLevelObjects = asset.childObjects(of: MDLObject.self) - - for mdlObject in topLevelObjects { - if mdlObject.name == name { - var meshGroup: [Mesh] = [] - - for child in mdlObject.children.objects { - if let mesh = child as? MDLMesh { - let meshes = makeMeshes(object: mesh, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: renderInfo.device, flip: true) - meshGroup.append(contentsOf: meshes) - } - } - - return meshGroup - } - } - - return [] - } - - /// Load a specific mesh by name asynchronously from a file URL - static func loadMeshWithNameAsync( - name: String, - url: URL, - vertexDescriptor: MDLVertexDescriptor, - device: MTLDevice, - coordinateConversion: CoordinateSystemConversion = .autoDetect, - progressHandler: ((Int, Int) -> Void)? = nil - ) async -> [Mesh] { - // Perform heavy I/O work on background thread - let matchedObject = await Task.detached { () -> MDLObject? in - let bufferAllocator = MTKMeshBufferAllocator(device: device) - - // Validate file exists - guard FileManager.default.fileExists(atPath: url.path) else { - handleError(.assetFileNotFound, url.path) - return nil - } - - // Wrap asset creation in error handling - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor, bufferAllocator: bufferAllocator) - - // Apply coordinate system conversion - let transform = orientationTransformForAsset(asset, conversion: coordinateConversion) - if transform != matrix_identity_float4x4 { - for object in asset.childObjects(of: MDLObject.self) { - object.transform = MDLTransform(matrix: simd_mul(transform, object.transform?.matrix ?? .identity)) - } - } - - // Load textures on background thread - asset.loadTextures() - - // Find the matching object - let topLevelObjects = asset.childObjects(of: MDLObject.self) - return topLevelObjects.first { $0.name == name } - }.value - - // Create Metal resources off-main to maximize parallelism across async loads. - guard let mdlObject = matchedObject else { - return [] - } - - let textureLoader = TextureLoader(device: device) - var meshGroup: [Mesh] = [] - - let children = mdlObject.children.objects - let totalChildren = children.count - let progressStride = totalChildren > 200 ? 10 : 1 - var processedCount = 0 - var lastReportedProgress = 0 - - for child in children { - if let mesh = child as? MDLMesh { - let meshes = makeMeshes(object: mesh, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: renderInfo.device, flip: true) - meshGroup.append(contentsOf: meshes) - } - - processedCount += 1 - if let progressHandler { - let shouldReport = processedCount == totalChildren || (processedCount - lastReportedProgress) >= progressStride - if shouldReport { - lastReportedProgress = processedCount - progressHandler(processedCount, totalChildren) - } - } - } - - return meshGroup - } - - /// Recursively find and create Mesh objects from ModelIO hierarchy + /// Convert an in-memory MDLObject (procedural geometry, not file-loaded) into engine Mesh objects. + /// Used by BasicPrimitives to turn MDLMesh shapes into renderable Mesh instances. static func makeMeshes(object: MDLObject, vertexDescriptor: MDLVertexDescriptor, textureLoader: TextureLoader, device: MTLDevice, flip: Bool) -> [Mesh] { - var meshes = [Mesh]() - - if let mdlMesh = object as? MDLMesh { - if let mesh = Mesh(modelIOMesh: mdlMesh, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip) { - meshes.append(mesh) - } else { - Logger.logWarning(message: "Skipped mesh '\(mdlMesh.name)' due to creation failure. Continuing with remaining meshes.") - } + var meshes: [Mesh] = [] + if let mdlMesh = object as? MDLMesh, + let mesh = Mesh(modelIOMesh: mdlMesh, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip) + { + meshes.append(mesh) } - if object.conforms(to: MDLObjectContainerComponent.self) { - for child in object.children.objects { - meshes.append(contentsOf: makeMeshes(object: child, vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip)) + let children = object.children.objects + for i in 0 ..< children.count { + meshes.append(contentsOf: makeMeshes(object: children[i], vertexDescriptor: vertexDescriptor, textureLoader: textureLoader, device: device, flip: flip)) } } - return meshes } @@ -950,26 +159,7 @@ public struct Mesh { /// Helper to create one fallback mesh static func makeDefaultMesh() -> [Mesh] { - let textureLoader = TextureLoader(device: renderInfo.device) - let bufferAllocator = MTKMeshBufferAllocator(device: renderInfo.device) - - let fallbackMDL = MDLMesh( - sphereWithExtent: [0.5, 0.5, 0.5], - segments: [32, 16], - inwardNormals: false, - geometryType: .triangles, - allocator: bufferAllocator - ) - fallbackMDL.name = "UntoldEngine_DefaultMesh" - let fallback = Mesh.makeMeshes(object: fallbackMDL, - vertexDescriptor: vertexDescriptor.model, - textureLoader: textureLoader, - device: renderInfo.device, - flip: true) - if fallback.isEmpty { - Logger.logWarning(message: "Default mesh group creation returned empty; check vertex descriptor/material pipeline.") - } - return fallback + BasicPrimitives.createSphere() } /// Build one engine `Mesh` from a source-agnostic runtime primitive. @@ -1928,9 +1118,7 @@ final class TextureLoader { /// Build a unique URL for an MDLTexture keyed by its object pointer. /// /// Used as the GPU texture cache key when bracket-notation path is absent. - /// The MDLAsset is kept alive in `ProgressiveAssetLoader.rootAssetRefs` for the - /// entire lifetime of the out-of-core asset, so the pointer is stable across - /// GPU-resource eviction/reload cycles. + /// The pointer is stable across GPU-resource eviction/reload cycles. /// /// Two MDLTexture objects with the SAME pointer are physically the same texture /// object and should share a GPU texture — this key correctly deduplicates them. diff --git a/Sources/UntoldEngine/Scenes/Builder/MeshNode.swift b/Sources/UntoldEngine/Scenes/Builder/MeshNode.swift index 95b310ff..18f04e6a 100644 --- a/Sources/UntoldEngine/Scenes/Builder/MeshNode.swift +++ b/Sources/UntoldEngine/Scenes/Builder/MeshNode.swift @@ -23,7 +23,7 @@ public class MeshNode: Node, NodeAnimations, NodeKinetics { if name == nil { setEntityName(entityId: self.entityID, name: resource) } - setEntityMesh(entityId: self.entityID, filename: resource.filename, withExtension: resource.extensionName) + setEntityMeshAsync(entityId: self.entityID, filename: resource.filename, withExtension: resource.extensionName) } public func materialData( diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 914becc2..19842888 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -24,6 +24,7 @@ public struct SceneData: Codable { var chromaticAberration: ChromaticAberrationData? = nil var depthOfField: DepthOfFieldData? = nil var ssao: SSAOData? = nil + var antiAliasing: AntiAliasingData? = nil } enum SceneAssetKind: String, Codable { @@ -100,6 +101,28 @@ struct SSAOData: Codable { var enabled: Bool? = false } +enum AntiAliasingModeData: String, Codable { + case none + case fxaa + case smaa +} + +struct FXAAData: Codable { + var subpixelQuality: Float = 0.75 + var edgeThreshold: Float = 0.125 + var edgeThresholdMin: Float = 0.0625 +} + +struct SMAAData: Codable { + var edgeThreshold: Float = 0.1 +} + +struct AntiAliasingData: Codable { + var mode: AntiAliasingModeData = .fxaa + var fxaa: FXAAData = .init() + var smaa: SMAAData = .init() +} + struct LightData: Codable { var color: simd_float3 = .one var radius: Float = 1.0 @@ -354,6 +377,24 @@ private func collectDerivedAnimationSourceURLs(rootEntityId: EntityID, currentEn } } +private func collectDerivedAssetNodeIds(rootEntityId: EntityID, currentEntityId: EntityID, derivedEntityIds: inout [EntityID]) { + for childId in getEntityChildren(parentId: currentEntityId) { + if let derivedComp = scene.get(component: DerivedAssetNodeComponent.self, for: childId), + derivedComp.assetRootEntityId == rootEntityId + { + derivedEntityIds.append(childId) + } + + collectDerivedAssetNodeIds(rootEntityId: rootEntityId, currentEntityId: childId, derivedEntityIds: &derivedEntityIds) + } +} + +private func derivedAssetNodeIds(rootEntityId: EntityID) -> [EntityID] { + var derivedEntityIds: [EntityID] = [] + collectDerivedAssetNodeIds(rootEntityId: rootEntityId, currentEntityId: rootEntityId, derivedEntityIds: &derivedEntityIds) + return derivedEntityIds +} + private func animationSourceURLsForSerialization(entityId: EntityID) -> [URL] { var urls: [URL] = [] var seen = Set() @@ -743,9 +784,8 @@ public func serializeScene() -> SceneData { if let assetInstanceComp = scene.get(component: AssetInstanceComponent.self, for: entityId) { // Collect overrides from derived descendants var overrides: [AssetOverrideData] = [] - let children = getEntityChildren(parentId: entityId) - for childId in children { + for childId in derivedAssetNodeIds(rootEntityId: entityId) { if let derivedComp = scene.get(component: DerivedAssetNodeComponent.self, for: childId) { // Only collect overrides if the derived node belongs to this asset instance if derivedComp.assetRootEntityId == entityId { @@ -880,6 +920,25 @@ public func serializeScene() -> SceneData { sceneData.ssao = SSAOData(radius: SSAOParams.shared.radius, bias: SSAOParams.shared.bias, intensity: SSAOParams.shared.intensity, enabled: SSAOParams.shared.enabled) + let antiAliasingModeData: AntiAliasingModeData + switch antiAliasingMode { + case .none: + antiAliasingModeData = .none + case .fxaa: + antiAliasingModeData = .fxaa + case .smaa: + antiAliasingModeData = .smaa + } + sceneData.antiAliasing = AntiAliasingData( + mode: antiAliasingModeData, + fxaa: FXAAData( + subpixelQuality: FXAAParams.shared.subpixelQuality, + edgeThreshold: FXAAParams.shared.edgeThreshold, + edgeThresholdMin: FXAAParams.shared.edgeThresholdMin + ), + smaa: SMAAData(edgeThreshold: SMAAParams.shared.edgeThreshold) + ) + // save asset base path sceneData.assetBasePath = assetBasePath @@ -1014,7 +1073,7 @@ public func deserializeScene( if let env = sceneData.environment { applyIBL = env.applyIBL ?? false renderEnvironment = env.renderEnvironment ?? false - ambientIntensity = env.ambientIntensity ?? 0.44 + ambientIntensity = env.ambientIntensity ?? 0.4 // Only generate HDR if IBL is explicitly enabled and HDR is specified if applyIBL, let hdr = env.hdr, !hdr.isEmpty { @@ -1079,6 +1138,22 @@ public func deserializeScene( } } + if let antiAliasing = sceneData.antiAliasing { + switch antiAliasing.mode { + case .none: + antiAliasingMode = .none + case .fxaa: + antiAliasingMode = .fxaa + case .smaa: + antiAliasingMode = .smaa + } + + FXAAParams.shared.subpixelQuality = antiAliasing.fxaa.subpixelQuality + FXAAParams.shared.edgeThreshold = antiAliasing.fxaa.edgeThreshold + FXAAParams.shared.edgeThresholdMin = antiAliasing.fxaa.edgeThresholdMin + SMAAParams.shared.edgeThreshold = antiAliasing.smaa.edgeThreshold + } + withWorldMutationGate { for sourceEntityData in sceneData.entities { var sceneDataEntity = sourceEntityData @@ -1135,20 +1210,19 @@ public func deserializeScene( switch meshLoadingMode { case .sync: - setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension, assetName: nil) - applyDeserializedLocalTransform(entityId: entityId, entityData: sceneDataEntity) - - // Restore Static Batch Component (sync mode - mesh already loaded) - if sceneDataEntity.hasStaticBatchComponent == true { - setEntityStaticBatchComponent(entityId: entityId) - } - // Apply overrides synchronously after import (must run after static restore so - // per-node static opt-outs can remove static from selected children). - applyAssetInstanceOverrides(entityId: entityId, overrides: assetInstance.overrides) - - // Setup animations (skeleton is now available) - if sceneDataEntity.hasAnimationComponent == true { - applyDeserializedAnimations(entityId: entityId, entityData: sceneDataEntity) + loadTracker.registerLoad() + setEntityMeshAsync(entityId: entityId, filename: filename, withExtension: withExtension, assetName: nil) { success in + applyDeserializedLocalTransform(entityId: entityId, entityData: sceneDataEntity) + if success { + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } + applyAssetInstanceOverrides(entityId: entityId, overrides: assetInstance.overrides) + if sceneDataEntity.hasAnimationComponent == true { + applyDeserializedAnimations(entityId: entityId, entityData: sceneDataEntity) + } + } + loadTracker.completeLoad() } case .asyncDefault: loadTracker.registerLoad() @@ -1191,19 +1265,20 @@ public func deserializeScene( setEntityStaticBatchComponent(entityId: entityId) } } else { - setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) - applyDeserializedLocalTransform(entityId: entityId, entityData: sceneDataEntity) - - // Restore Static Batch Component (sync mode - mesh already loaded) - if sceneDataEntity.hasStaticBatchComponent == true { - setEntityStaticBatchComponent(entityId: entityId) + loadTracker.registerLoad() + setEntityMeshAsync(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) { success in + applyDeserializedLocalTransform(entityId: entityId, entityData: sceneDataEntity) + if success { + if sceneDataEntity.hasStaticBatchComponent == true { + setEntityStaticBatchComponent(entityId: entityId) + } + if sceneDataEntity.hasAnimationComponent == true { + applyDeserializedAnimations(entityId: entityId, entityData: sceneDataEntity) + } + } + loadTracker.completeLoad() } } - - // Setup animations (skeleton is now available) - if sceneDataEntity.hasAnimationComponent == true { - applyDeserializedAnimations(entityId: entityId, entityData: sceneDataEntity) - } case .asyncDefault: if isProcedural { let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName) @@ -1653,9 +1728,8 @@ public extension Notification.Name { private func applyAssetInstanceOverrides(entityId: EntityID, overrides: [AssetOverrideData]) { // Build nodePath -> derived entity map var nodePathMap: [String: EntityID] = [:] - let children = getEntityChildren(parentId: entityId) - for childId in children { + for childId in derivedAssetNodeIds(rootEntityId: entityId) { if let derivedComp = scene.get(component: DerivedAssetNodeComponent.self, for: childId) { nodePathMap[derivedComp.nodePath] = childId } diff --git a/Sources/UntoldEngine/Systems/AssetProfiler.swift b/Sources/UntoldEngine/Systems/AssetProfiler.swift index aac64baa..cd522adf 100644 --- a/Sources/UntoldEngine/Systems/AssetProfiler.swift +++ b/Sources/UntoldEngine/Systems/AssetProfiler.swift @@ -9,17 +9,10 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation -import MetalKit -import ModelIO // MARK: - Asset Profile /// A lightweight snapshot of an asset's composition used to select the optimal loading policy. -/// -/// All byte estimates are rough order-of-magnitude approximations computed at registration -/// time — no texture decompression or full GPU allocation is performed. `AssetProfiler` is -/// designed to run in < a few milliseconds inside the async registration task, well within -/// the cost of `Mesh.parseAssetAsync()` itself. public struct AssetProfile: Sendable { // MARK: Byte Estimates @@ -27,20 +20,17 @@ public struct AssetProfile: Sendable { public let totalFileBytes: Int /// Estimated GPU geometry footprint: vertex + index buffers summed across all meshes. - /// Uses the same vertex-stride formula as `CPUMeshEntry.estimatedGPUBytes`. public let estimatedGeometryBytes: Int - /// Estimated GPU texture footprint. Derived from texture URL file sizes with a - /// decompression expansion factor, or a heuristic when textures are embedded in USDZ. - /// May be 0 if no material texture references could be resolved. + /// Estimated GPU texture footprint. public let estimatedTextureBytes: Int // MARK: Structural Signals - /// Number of top-level `MDLMesh` objects in the parsed asset. + /// Number of top-level mesh objects in the parsed asset. public let meshCount: Int - /// Total number of `MDLSubmesh` / material slots encountered across all meshes. + /// Total number of material slots encountered across all meshes. public let materialCount: Int /// GPU byte estimate for the single largest individual mesh in the asset. @@ -77,108 +67,8 @@ public struct AssetProfile: Sendable { // MARK: - Asset Profiler -/// Analyzes a parsed `ProgressiveAssetData` to produce an `AssetProfile` and recommend -/// the most appropriate `AssetLoadingPolicy` for the current platform memory budget. -/// -/// ## Usage -/// ```swift -/// let profile = AssetProfiler.profile(url: url, assetData: assetData, fileSizeBytes: fileSize) -/// let policy = AssetProfiler.classifyPolicy(profile: profile, budget: MemoryBudgetManager.shared.meshBudget) -/// ``` -/// -/// `AssetProfiler` runs after `Mesh.parseAssetAsync()` returns a `ProgressiveAssetData` and -/// before any ECS entity or GPU resource is created, so it has access to all `MDLMesh` -/// objects but no Metal allocations have occurred yet. +/// Classifies asset loading policy from an `AssetProfile` and the current memory budget. public enum AssetProfiler { - // MARK: - Profile - - /// Build an `AssetProfile` from a parsed asset. - /// - /// - Parameters: - /// - url: Source URL of the asset (used only to stat on-disk size when `fileSizeBytes == 0`). - /// - assetData: Result of `Mesh.parseAssetAsync()`. - /// - fileSizeBytes: Pre-computed on-disk file size in bytes. Pass 0 to skip (estimates only). - /// - Returns: A populated `AssetProfile`. - static func profile( - url _: URL, - assetData: Mesh.ProgressiveAssetData, - fileSizeBytes: Int - ) -> AssetProfile { - var totalGeometryBytes = 0 - var largestMeshBytes = 0 - var materialCount = 0 - var uniqueTextureRefs = Set() - var meshCount = 0 - - for obj in assetData.topLevelObjects { - guard let mesh = obj as? MDLMesh else { continue } - meshCount += 1 - - // Geometry estimate: vertex bytes + index bytes. - // Identical formula to CPUMeshEntry.estimatedGPUBytes so the profiler and the - // budget manager agree on per-mesh costs. - let stride = Int( - (mesh.vertexDescriptor.layouts.firstObject as? MDLVertexBufferLayout)?.stride ?? 48 - ) - let vertexBytes = mesh.vertexCount * stride - let indexBytes = mesh.vertexCount * 3 * 4 // ~3 indices/vertex, 4 bytes each - let meshBytes = vertexBytes + indexBytes - totalGeometryBytes += meshBytes - largestMeshBytes = max(largestMeshBytes, meshBytes) - - // Scan submesh materials for texture URL references. - if let submeshes = mesh.submeshes { - for case let submesh as MDLSubmesh in submeshes { - guard let material = submesh.material else { continue } - materialCount += 1 - scanMaterialForTextureRefs(material, into: &uniqueTextureRefs) - } - } - } - - // Estimate total texture GPU footprint from the collected URL references. - let textureBytes = estimateTextureBytes( - textureRefs: uniqueTextureRefs, - fileSizeBytes: fileSizeBytes, - geometryBytes: totalGeometryBytes - ) - - // Classify the asset's dominant memory domain. - let isMonolithic = meshCount <= 2 - let assetCharacter: AssetProfile.AssetCharacter - - if isMonolithic { - assetCharacter = .monolithic - } else { - let total = totalGeometryBytes + textureBytes - if total == 0 { - assetCharacter = .mixed - } else { - let textureFraction = Float(textureBytes) / Float(total) - let geoFraction = Float(totalGeometryBytes) / Float(total) - switch (textureFraction, geoFraction) { - case let (t, _) where t > 0.75: - assetCharacter = .textureDominated - case let (_, g) where g > 0.75: - assetCharacter = .geometryDominated - default: - assetCharacter = .mixed - } - } - } - - return AssetProfile( - totalFileBytes: fileSizeBytes, - estimatedGeometryBytes: totalGeometryBytes, - estimatedTextureBytes: textureBytes, - meshCount: meshCount, - materialCount: materialCount, - largestSingleMeshBytes: largestMeshBytes, - isEffectivelyMonolithic: isMonolithic, - assetCharacter: assetCharacter - ) - } - // MARK: - Classify Policy /// Recommend an `AssetLoadingPolicy` for the given profile and platform memory budget. @@ -253,84 +143,4 @@ public enum AssetProfiler { return .eager } - - // MARK: - Private: Texture Estimation Helpers - - /// Collect texture URL strings from an MDLMaterial's known semantic slots. - /// - /// Checks `urlValue` and `stringValue` for each semantic. Both regular file URLs and - /// USDZ bracket-notation strings (e.g. `"file:///asset.usdz[0/texture.png]"`) are captured. - private static func scanMaterialForTextureRefs( - _ material: MDLMaterial, - into refs: inout Set - ) { - let textureSemantics: [MDLMaterialSemantic] = [ - .baseColor, - .roughness, - .metallic, - .bump, - .emission, - .opacity, - .ambientOcclusion, - ] - for semantic in textureSemantics { - guard let prop = material.property(with: semantic) else { continue } - if let url = prop.urlValue { - refs.insert(url.absoluteString) - } else if let str = prop.stringValue, !str.isEmpty { - refs.insert(str) - } - } - } - - /// Estimate the GPU texture footprint from a set of texture URL strings. - /// - /// **Regular file URLs**: stats the file and multiplies by 3× (conservative PNG/JPEG - /// decode expansion; ASTC expands ~6–8× but is less common for external files). - /// - /// **USDZ-embedded textures** (bracket-notation URLs): cannot stat individual zip entries - /// without decompressing. Uses a single heuristic: `(fileSize − geometryBytes) × 3`. - /// This overestimates for geometry-heavy USDZs but is safe to err high (only triggers - /// more streaming, never less). - /// - /// **No URLs found**: returns 0 (profiler leaves texture policy to the default). - private static func estimateTextureBytes( - textureRefs: Set, - fileSizeBytes: Int, - geometryBytes: Int - ) -> Int { - guard !textureRefs.isEmpty else { return 0 } - - var totalBytes = 0 - var hasEmbeddedTextures = false - - for ref in textureRefs { - if ref.contains("[") { - // USDZ bracket-notation — embedded textures; use a file-level heuristic below. - hasEmbeddedTextures = true - } else if let textureURL = URL(string: ref), textureURL.isFileURL { - let fileSize = (try? FileManager.default.attributesOfItem(atPath: textureURL.path))?[.size] as? Int ?? 0 - if textureURL.pathExtension.lowercased() == "utex" { - // .utex is the engine-native ASTC container: the file payload IS the GPU - // data (ASTC blocks transferred as-is). No decode expansion — file size - // ≈ GPU allocation. Use 1× with no multiplier. - totalBytes += fileSize - } else { - // PNG/JPEG typically decode to 3–4× their compressed size on the GPU. - totalBytes += fileSize * 3 - } - } - } - - // For embedded USDZ textures where we cannot stat individual entries: - // estimate total packed texture bytes as (fileSize − estimated_packed_geometry). - // Packed geometry is roughly 1/10 of its uncompressed GPU size. - if hasEmbeddedTextures, totalBytes == 0 { - let estimatedPackedGeometry = geometryBytes / 10 - let estimatedPackedTextureBytes = max(0, fileSizeBytes - estimatedPackedGeometry) - totalBytes = estimatedPackedTextureBytes * 3 - } - - return totalBytes - } } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift index 7f2eda6a..fd793357 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift @@ -3,8 +3,7 @@ // UntoldEngine // // OOC mesh streaming: loadMesh, unloadMesh, and all async loading helpers -// (loadMeshAsync, uploadFromCPUEntry, uploadActiveLODFromCPU, reloadLODEntity, -// rehydrateColdAsset, estimateTextureSizeBytes). +// (loadMeshAsync, uploadFromRuntimeEntry, reloadLODEntity). // // // Copyright (C) Untold Engine Studios @@ -14,7 +13,6 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation -import ModelIO import simd extension GeometryStreamingSystem { @@ -41,9 +39,7 @@ extension GeometryStreamingSystem { ) } - // Check if entity has LOD component and CPU LOD data (LOD+OOC path) let hasLOD = scene.get(component: LODComponent.self, for: entityId) != nil - let hasCPULODData = hasLOD && ProgressiveAssetLoader.shared.hasCPULODData(for: entityId) let filename = streaming.assetFilename let ext = streaming.assetExtension @@ -51,11 +47,8 @@ extension GeometryStreamingSystem { let task = Task { let asyncLoadStart = CFAbsoluteTimeGetCurrent() - let success = if hasCPULODData { - // LOD+OOC entity: upload all LOD levels from CPU registry (no disk I/O) - await uploadActiveLODFromCPU(entityId: entityId) - } else if hasLOD { - // LOD entity (disk-based): reload all LOD levels and set correct one for current distance + let success = if hasLOD { + // LOD entity: reload all LOD levels and set correct one for current distance await reloadLODEntity(entityId: entityId) } else { // Regular entity: load single mesh from disk / cache @@ -238,375 +231,57 @@ extension GeometryStreamingSystem { return true } - /// Upload one out-of-core stub entity from CPU-resident MDLMesh data to Metal. + /// Upload a .untold OCC stub entity from its CPU-resident RuntimeAssetNode (no disk I/O). /// - /// Called instead of the disk-based `MeshResourceManager` path when the entity was - /// registered by the out-of-core stub system. CPU→Metal copy happens here; no USDZ - /// re-read is required. The CPU data is NOT cleared after upload so that future - /// eviction+reload cycles can re-upload from the same in-memory source. - func uploadFromCPUEntry( + /// Called by loadMeshAsync when ProgressiveAssetLoader.hasCPURuntimeData returns true. + /// Calls makeMeshes(from:) off the main thread to create Metal buffers, then registers + /// the RenderComponent on the main thread. No texture lock is needed — RuntimeAssetNode + /// vertex/index data is self-contained in value-type Data blobs. + func uploadFromRuntimeEntry( entityId: EntityID, - cpuEntry: ProgressiveAssetLoader.CPUMeshEntry + runtimeEntry: ProgressiveAssetLoader.CPURuntimeEntry ) async -> Bool { - // Guard against the cooperative-cancellation race: the parent tile may have been - // unloaded while this Task was in flight (Swift Task cancellation is cooperative — - // the task runs to completion even after cancel() is called). If the entity slot - // has been freed or reused by a new entity (version mismatch), bail out early so - // subsequent scene.get() calls do not generate spurious 1016 "entity missing" errors. guard scene.exists(entityId) else { return false } - // Serialize texture loading per asset and ensure loadTextures() has been called. - // MDLAsset is not thread-safe. The lock prevents two concurrent uploads from the - // same asset racing on MDLTexture internal state. - // ensureTexturesLoaded() is a no-op after the first call per asset — it calls - // asset.loadTextures() exactly once, deferred from parse time to first-upload time - // so the full texture decompression spike doesn't happen before any mesh is rendered. - let rootEntityId = scene.get(component: DerivedAssetNodeComponent.self, for: entityId)?.assetRootEntityId - var lockWaitMs: Double = 0 - var textureMs: Double = 0 - if let rootId = rootEntityId { - // [Instrumentation] Measure time blocked waiting for the per-asset texture lock. - let lockStart = CFAbsoluteTimeGetCurrent() - ProgressiveAssetLoader.shared.acquireAssetTextureLock(for: rootId) - lockWaitMs = (CFAbsoluteTimeGetCurrent() - lockStart) * 1000.0 - - // [Instrumentation] Measure ensureTexturesLoaded duration. - // Non-zero only on the FIRST upload from this asset; subsequent calls are no-ops. - let textureStart = CFAbsoluteTimeGetCurrent() - // Always call ensureTexturesLoaded before makeMeshesFromCPUBuffers. This calls - // asset.loadTextures() exactly once per asset — USDZ-embedded textures require it - // before MTKTextureLoader can decode them. The lock scope ends here: the MDLAsset - // is in a stable read-only state after loadTextures() and concurrent GPU uploads - // from the same asset are safe without the lock. - ProgressiveAssetLoader.shared.ensureTexturesLoaded(for: rootId) - textureMs = (CFAbsoluteTimeGetCurrent() - textureStart) * 1000.0 - ProgressiveAssetLoader.shared.releaseAssetTextureLock(for: rootId) - } - // [Instrumentation] Log estimated GPU size vs. available budget before attempting upload. - // This log line is the first indicator of an OOM-related crash: if the app dies - // immediately after this line, the tile was too large for the remaining GPU budget. - let estimatedUploadMB = Float(cpuEntry.estimatedGPUBytes) / (1024.0 * 1024.0) + let estimatedUploadMB = Float(runtimeEntry.estimatedGPUBytes) / (1024.0 * 1024.0) let preBudgetStats = MemoryBudgetManager.shared.getStats() let availGeomMB = Float(max(0, preBudgetStats.geometryBudget - preBudgetStats.meshMemoryUsed)) / (1024.0 * 1024.0) Logger.log( - message: "[TileUpload] Entity \(entityId) '\(cpuEntry.uniqueAssetName)': estimatedGPU=\(String(format: "%.1f", estimatedUploadMB)) MB, geomAvailable=\(String(format: "%.1f", availGeomMB)) MB, geomUsed=\(String(format: "%.0f", preBudgetStats.geometryUtilization * 100))%", + message: "[TileUpload] Entity \(entityId) '\(runtimeEntry.uniqueAssetName)': estimatedGPU=\(String(format: "%.1f", estimatedUploadMB)) MB, geomAvailable=\(String(format: "%.1f", availGeomMB)) MB, geomUsed=\(String(format: "%.0f", preBudgetStats.geometryUtilization * 100))%", category: LogCategory.oocStatus.rawValue ) - // [Instrumentation] Measure CPU→Metal buffer copy time. - let copyStart = CFAbsoluteTimeGetCurrent() - let meshes = Mesh.makeMeshesFromCPUBuffers( - object: cpuEntry.object, - vertexDescriptor: cpuEntry.vertexDescriptor, - textureLoader: cpuEntry.textureLoader, - device: cpuEntry.device, - flip: true - ) - let copyMs = (CFAbsoluteTimeGetCurrent() - copyStart) * 1000.0 - Logger.log( - message: "[OOC-Timing] Entity \(entityId) '\(cpuEntry.uniqueAssetName)': lockWait=\(String(format: "%.1f", lockWaitMs))ms textures=\(String(format: "%.1f", textureMs))ms cpuToMetal=\(String(format: "%.1f", copyMs))ms", - category: LogCategory.oocTiming.rawValue - ) + let meshes = makeMeshes(from: runtimeEntry.node) guard !meshes.isEmpty else { - handleError(.meshStreamingFailed, "CPU→Metal upload failed for '\(cpuEntry.uniqueAssetName)' (estimated \(String(format: "%.1f", estimatedUploadMB)) MB, geomAvailable \(String(format: "%.1f", availGeomMB)) MB)", entityId) + handleError(.meshStreamingFailed, "CPU→Metal upload failed for '\(runtimeEntry.uniqueAssetName)' (estimated \(String(format: "%.1f", estimatedUploadMB)) MB, geomAvailable \(String(format: "%.1f", availGeomMB)) MB)", entityId) return false } - // Stamp the unique asset name so the RenderComponent matches the StreamingComponent. let namedMeshes = meshes.map { m -> Mesh in var copy = m - copy.assetName = cpuEntry.uniqueAssetName + copy.assetName = runtimeEntry.uniqueAssetName return copy } withWorldMutationGate { - // Guard against the cooperative-cancellation race: unloadTile may have freed - // this entity while the CPU→Metal copy was in flight. If the entity no longer - // exists, skip registration — the outer Task's scene.exists guard will clean up. guard scene.exists(entityId) else { return } registerRenderComponent( entityId: entityId, meshes: namedMeshes, - url: cpuEntry.url, - assetName: cpuEntry.uniqueAssetName + url: runtimeEntry.url, + assetName: runtimeEntry.uniqueAssetName ) } - // 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, textureSizeBytes: 0 ) - // CPU data is intentionally kept alive in ProgressiveAssetLoader.cpuMeshRegistry - // so eviction + re-approach triggers another uploadFromCPUEntry, not a disk read. - return true - } - - /// Upload all LOD levels for an LOD+OOC entity from the CPU registry (no disk I/O). - /// - /// Mirrors `reloadLODEntity` but reads MDLObject data from `ProgressiveAssetLoader.cpuLODRegistry` - /// instead of re-reading from disk. After all levels are uploaded, the render component is set to - /// the LOD level appropriate for the current camera distance — identical selection logic to `reloadLODEntity`. - func uploadActiveLODFromCPU(entityId: EntityID) async -> Bool { - // Guard against the cooperative-cancellation race: bail out early if the entity has - // been freed or its slot reused (version mismatch) so subsequent scene.get() calls - // do not generate spurious 1016 "entity missing" errors. - guard scene.exists(entityId) else { return false } - - // Determine root entity for texture lock serialization. - let rootEntityId = scene.get(component: DerivedAssetNodeComponent.self, for: entityId)? - .assetRootEntityId ?? entityId - - // If the root asset has gone cold, re-parse from disk to restore CPU entries. - if ProgressiveAssetLoader.shared.isColdRoot(rootEntityId) { - guard let context = ProgressiveAssetLoader.shared.rehydrationContext(for: rootEntityId) else { - handleError(.coldRehydrationFailed, "root \(rootEntityId) has no rehydration context", entityId) - return false - } - let ok = await rehydrateColdAsset(rootEntityId: rootEntityId, context: context) - guard ok else { return false } - } - - guard let allLODEntries = ProgressiveAssetLoader.shared.retrieveAllCPULODMeshes(for: entityId), - !allLODEntries.isEmpty - else { - handleError(.meshStreamingFailed, "no CPU LOD entries found", entityId) - return false - } - - // Ensure loadTextures() has been called before any MTKTextureLoader decoding. - // The lock scope covers only ensureTexturesLoaded — the MDLAsset is read-only after - // that point and concurrent GPU uploads across LOD levels are safe without it. - ProgressiveAssetLoader.shared.acquireAssetTextureLock(for: rootEntityId) - ProgressiveAssetLoader.shared.ensureTexturesLoaded(for: rootEntityId) - ProgressiveAssetLoader.shared.releaseAssetTextureLock(for: rootEntityId) - - // [Instrumentation] Log total estimated GPU bytes across all LOD levels vs. available budget. - let totalLODEstimatedBytes = allLODEntries.values.reduce(0) { $0 + $1.estimatedGPUBytes } - let totalLODEstimatedMB = Float(totalLODEstimatedBytes) / (1024.0 * 1024.0) - let lodPreBudgetStats = MemoryBudgetManager.shared.getStats() - let lodAvailGeomMB = Float(max(0, lodPreBudgetStats.geometryBudget - lodPreBudgetStats.meshMemoryUsed)) / (1024.0 * 1024.0) - Logger.log( - message: "[TileUpload] LOD+OOC entity \(entityId): \(allLODEntries.count) level(s), totalEstimatedGPU=\(String(format: "%.1f", totalLODEstimatedMB)) MB, geomAvailable=\(String(format: "%.1f", lodAvailGeomMB)) MB, geomUsed=\(String(format: "%.0f", lodPreBudgetStats.geometryUtilization * 100))%", - category: LogCategory.oocStatus.rawValue - ) - - // Upload every LOD level from CPU to Metal. - var uploadedMeshes: [Int: [Mesh]] = [:] - for (lodIndex, cpuEntry) in allLODEntries { - let meshes = Mesh.makeMeshesFromCPUBuffers( - object: cpuEntry.object, - vertexDescriptor: cpuEntry.vertexDescriptor, - textureLoader: cpuEntry.textureLoader, - device: cpuEntry.device, - flip: true - ) - guard !meshes.isEmpty else { - Logger.logWarning( - message: "[OutOfCore] LOD+OOC entity \(entityId): CPU→Metal failed for LOD\(lodIndex), skipping level", - category: LogCategory.oocStatus.rawValue - ) - continue - } - let levelSkin = Skin() - var namedMeshes = meshes.map { m -> Mesh in var copy = m; copy.assetName = cpuEntry.uniqueAssetName; return copy } - for i in namedMeshes.indices where namedMeshes[i].skin == nil { - namedMeshes[i].skin = levelSkin - } - uploadedMeshes[lodIndex] = namedMeshes - } - - guard !uploadedMeshes.isEmpty else { - handleError(.meshStreamingFailed, "all LOD level uploads failed", entityId) - return false - } - - withWorldMutationGate { - guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { return } - - // Store uploaded meshes in LOD levels and mark resident. - for (lodIndex, meshes) in uploadedMeshes { - guard lodIndex < lodComponent.lodLevels.count else { continue } - lodComponent.lodLevels[lodIndex].mesh = meshes - lodComponent.lodLevels[lodIndex].residencyState = .resident - } - - // Select correct LOD for current camera distance (same logic as reloadLODEntity). - var selectedLOD = lodComponent.lodLevels.count - 1 - if let camera = CameraSystem.shared.activeCamera, - let cameraComponent = scene.get(component: CameraComponent.self, for: camera), - let transform = scene.get(component: WorldTransformComponent.self, for: entityId), - let local = scene.get(component: LocalTransformComponent.self, for: entityId) - { - 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)) - for (index, level) in lodComponent.lodLevels.enumerated() { - if distance <= level.maxDistance, lodComponent.isLODResident(index) { - selectedLOD = index - break - } - } - } - - if selectedLOD < lodComponent.lodLevels.count, lodComponent.isLODResident(selectedLOD) { - let lodLevel = lodComponent.lodLevels[selectedLOD] - if let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPULODMesh(for: entityId, lodIndex: selectedLOD) { - registerRenderComponent(entityId: entityId, meshes: lodLevel.mesh, url: cpuEntry.url, assetName: cpuEntry.uniqueAssetName) - } - lodComponent.currentLOD = selectedLOD - lodComponent.desiredLOD = selectedLOD - lodComponent.isUsingFallback = false - } - } - - // 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) - - Logger.log( - message: "[OutOfCore] LOD+OOC entity \(entityId): uploaded \(uploadedMeshes.count) LOD level(s) from CPU", - category: LogCategory.oocStatus.rawValue - ) - return true - } - - /// Re-parse a cold root asset from disk and restore all child CPU entries. - /// - /// At most one re-parse Task runs per root at a time: `getOrCreateRehydrationTask` ensures - /// concurrent child entity requests all await the same `Task` rather than - /// each launching a duplicate re-parse. Once complete, the root transitions back to warm - /// via `markAsWarm` and all child `CPUMeshEntry` objects are restored in `cpuMeshRegistry`. - func rehydrateColdAsset( - rootEntityId: EntityID, - context: ProgressiveAssetLoader.RootRehydrationContext - ) async -> Bool { - let task = ProgressiveAssetLoader.shared.getOrCreateRehydrationTask(for: rootEntityId) { - Task { - Logger.log( - message: "[OutOfCore] Cold re-stream: re-parsing '\(context.url.lastPathComponent)' for root \(rootEntityId)", - category: LogCategory.oocStatus.rawValue - ) - guard let assetData = await Mesh.parseAssetAsync( - url: context.url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device - ) else { - handleError(.coldRehydrationFailed, "parseAssetAsync failed", rootEntityId) - ProgressiveAssetLoader.shared.clearRehydrationTask(for: rootEntityId) - return false - } - - let children = ProgressiveAssetLoader.shared.getChildren(for: rootEntityId) - let filename = context.url.deletingPathExtension().lastPathComponent - let ext = context.url.pathExtension - - // Detect whether this is a LOD+OOC asset by checking if the re-parsed - // top-level objects form LOD groups (same detection as registration time). - let topLevelNames = assetData.topLevelObjects.map { - ($0 as? MDLMesh)?.parent?.name ?? $0.name - } - let lodDetection = detectImportedLODGroups(fromSourceNames: topLevelNames) - - if !lodDetection.groups.isEmpty, !children.isEmpty { - // LOD+OOC: rebuild cpuLODRegistry from detected groups. - // Groups are sorted by baseName (same order as at registration time), - // so children[groupIdx] corresponds to lodDetection.groups[groupIdx]. - var nameToObject: [String: MDLObject] = [:] - for obj in assetData.topLevelObjects { - let name = (obj as? MDLMesh)?.parent?.name ?? obj.name - nameToObject[name] = obj - } - var restoredEntries = 0 - for (groupIdx, group) in lodDetection.groups.enumerated() { - guard groupIdx < children.count else { break } - let groupEntityId = children[groupIdx] - for level in group.levels { - guard let obj = nameToObject[level.sourceName] else { continue } - let estimatedGPUBytes: Int = { - guard let mdlMesh = obj as? MDLMesh else { return 0 } - let stride = Int((mdlMesh.vertexDescriptor.layouts.firstObject as? MDLVertexBufferLayout)?.stride ?? 48) - return mdlMesh.vertexCount * stride + mdlMesh.vertexCount * 3 * 4 - }() - let entry = ProgressiveAssetLoader.CPUMeshEntry( - object: obj, - vertexDescriptor: vertexDescriptor.model, - textureLoader: assetData.textureLoader, - device: renderInfo.device, - url: context.url, - filename: filename, - withExtension: ext, - uniqueAssetName: level.sourceName, - estimatedGPUBytes: estimatedGPUBytes, - residencyPolicy: context.loadingPolicy - ) - ProgressiveAssetLoader.shared.storeCPULODMesh(entry, for: groupEntityId, lodIndex: level.lodIndex) - restoredEntries += 1 - } - } - ProgressiveAssetLoader.shared.storeAsset(assetData.asset, for: rootEntityId) - ProgressiveAssetLoader.shared.markAsWarm(rootEntityId: rootEntityId) - Logger.log( - message: "[OutOfCore] Cold re-stream complete (LOD+OOC): root \(rootEntityId) is warm (\(restoredEntries) LOD entries restored across \(lodDetection.groups.count) group(s))", - category: LogCategory.oocStatus.rawValue - ) - } else { - // Regular OOC: rebuild cpuMeshRegistry, one entry per child stub entity. - for (i, obj) in assetData.topLevelObjects.enumerated() { - guard i < children.count else { break } - let childId = children[i] - let baseName = (obj as? MDLMesh)?.parent?.name ?? obj.name - let uniqueName = "\(baseName)#\(i)" - let estimatedGPUBytes: Int = { - guard let mdlMesh = obj as? MDLMesh else { return 0 } - let stride = Int((mdlMesh.vertexDescriptor.layouts.firstObject as? MDLVertexBufferLayout)?.stride ?? 48) - let vertexBytes = mdlMesh.vertexCount * stride - let indexBytes = mdlMesh.vertexCount * 3 * 4 - return vertexBytes + indexBytes - }() - let entry = ProgressiveAssetLoader.CPUMeshEntry( - object: obj, - vertexDescriptor: vertexDescriptor.model, - textureLoader: assetData.textureLoader, - device: renderInfo.device, - url: context.url, - filename: filename, - withExtension: ext, - uniqueAssetName: uniqueName, - estimatedGPUBytes: estimatedGPUBytes, - residencyPolicy: context.loadingPolicy - ) - ProgressiveAssetLoader.shared.storeCPUMesh(entry, for: childId) - } - ProgressiveAssetLoader.shared.storeAsset(assetData.asset, for: rootEntityId) - ProgressiveAssetLoader.shared.markAsWarm(rootEntityId: rootEntityId) - Logger.log( - message: "[OutOfCore] Cold re-stream complete: root \(rootEntityId) is warm (\(min(assetData.topLevelObjects.count, children.count)) entries restored)", - category: LogCategory.oocStatus.rawValue - ) - } - return true - } - } - return await task.value + return scene.exists(entityId) } /// Load mesh asynchronously - returns true on success, false on failure @@ -621,29 +296,10 @@ extension GeometryStreamingSystem { // do not generate spurious 1016 "entity missing" errors. guard scene.exists(entityId) else { return false } - // Out-of-core fast path: entity has CPU-resident MDLMesh data from stub registration. - // Upload from RAM — no disk I/O, no MeshResourceManager parse. - if let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId) { - return await uploadFromCPUEntry(entityId: entityId, cpuEntry: cpuEntry) - } - - // Out-of-core cold re-stream path: CPU data was released via releaseWarmAsset() but - // the entity has a rehydration context (URL + policy). Re-parse from disk, restore - // all child CPU entries, then upload this entity from the freshly-parsed data. - if let rootId = scene.get(component: DerivedAssetNodeComponent.self, for: entityId)?.assetRootEntityId, - ProgressiveAssetLoader.shared.isColdRoot(rootId), - let context = ProgressiveAssetLoader.shared.rehydrationContext(for: rootId) - { - let rehydrated = await rehydrateColdAsset(rootEntityId: rootId, context: context) - guard rehydrated else { - handleError(.coldRehydrationFailed, "root \(rootId)") - return false - } - guard let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId) else { - handleError(.coldRehydrationRegistryNotRestored, "entity \(entityId) under root \(rootId)") - return false - } - return await uploadFromCPUEntry(entityId: entityId, cpuEntry: cpuEntry) + // .untold OCC fast path: entity has CPU-resident RuntimeAssetNode from stub registration. + // Upload from RAM via makeMeshes(from:) — no disk I/O, no MeshResourceManager parse. + if let runtimeEntry = ProgressiveAssetLoader.shared.retrieveCPURuntimeEntry(for: entityId) { + return await uploadFromRuntimeEntry(entityId: entityId, runtimeEntry: runtimeEntry) } // Build URL @@ -712,48 +368,6 @@ extension GeometryStreamingSystem { 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. - 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) - } - func unloadMesh(entityId: EntityID) { guard let streaming = scene.get(component: StreamingComponent.self, for: entityId), streaming.state == .loaded diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift index c60cb0d5..20cbd41e 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem.swift @@ -1132,19 +1132,8 @@ public class GeometryStreamingSystem: @unchecked Sendable { // 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)") - } + // TODO: release .untold OCC CPU heap entries under critical pressure + // (CPURuntimeEntry Data blobs per stub entity). } evictionTriggered = true } @@ -1237,28 +1226,19 @@ public class GeometryStreamingSystem: @unchecked Sendable { // Dispatching them wastes a slot on a disk-path fallback that will fail — // CPU entries are populated by the registration system shortly after this tick. // Cold roots are exempt: they rehydrate intentionally from disk. - if let rootId = scene.get(component: DerivedAssetNodeComponent.self, for: entityId)?.assetRootEntityId { - // Skip entities whose CPU data isn't registered yet (pre-streaming slot jam). - if !ProgressiveAssetLoader.shared.isColdRoot(rootId), - ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId) == nil, - !ProgressiveAssetLoader.shared.hasCPULODData(for: entityId) - { - continue - } - // Defer dispatch until background prewarm releases the per-asset texture lock. - // Dispatching while prewarm holds the lock blocks the first batch for the full - // remaining prewarm duration (~1-2 s). Wait until lockWait ≈ 0. - if ProgressiveAssetLoader.shared.isPrewarmActive(for: rootId) { - continue - } + // Skip .untold OCC stubs whose CPU data isn't registered yet. + if ProgressiveAssetLoader.shared.hasCPURuntimeData(for: entityId) == false, + scene.get(component: DerivedAssetNodeComponent.self, for: entityId) != nil + { + continue } // Per-candidate geometry budget check: evict if this mesh won't fit. - if let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId), - !MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: cpuEntry.estimatedGPUBytes) + if let runtimeEntry = ProgressiveAssetLoader.shared.retrieveCPURuntimeEntry(for: entityId), + !MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: runtimeEntry.estimatedGPUBytes) { evictedByLRU += evictLRU(cameraPosition: effectiveCameraPosition) evictionTriggered = true - guard MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: cpuEntry.estimatedGPUBytes) else { continue } + guard MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: runtimeEntry.estimatedGPUBytes) else { continue } } loadMesh(entityId: entityId, isNearBand: true) startedLoads += 1 @@ -1270,26 +1250,19 @@ public class GeometryStreamingSystem: @unchecked Sendable { var restDispatched = 0 for (entityId, _, _, _) in restBandCandidates { guard restDispatched < restSlots else { break } - // Same guard: skip OOC child entities whose CPU data isn't ready yet. - if let rootId = scene.get(component: DerivedAssetNodeComponent.self, for: entityId)?.assetRootEntityId { - if !ProgressiveAssetLoader.shared.isColdRoot(rootId), - ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId) == nil, - !ProgressiveAssetLoader.shared.hasCPULODData(for: entityId) - { - continue - } - // Defer until background prewarm releases the texture lock. - if ProgressiveAssetLoader.shared.isPrewarmActive(for: rootId) { - continue - } + // Skip .untold OCC stubs whose CPU data isn't registered yet. + if ProgressiveAssetLoader.shared.hasCPURuntimeData(for: entityId) == false, + scene.get(component: DerivedAssetNodeComponent.self, for: entityId) != nil + { + continue } // Per-candidate geometry budget check for out-of-core rest-band entities. - if let cpuEntry = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId), - !MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: cpuEntry.estimatedGPUBytes) + if let runtimeEntry = ProgressiveAssetLoader.shared.retrieveCPURuntimeEntry(for: entityId), + !MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: runtimeEntry.estimatedGPUBytes) { evictedByLRU += evictLRU(cameraPosition: effectiveCameraPosition) evictionTriggered = true - guard MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: cpuEntry.estimatedGPUBytes) else { continue } + guard MemoryBudgetManager.shared.canAcceptMesh(sizeBytes: runtimeEntry.estimatedGPUBytes) else { continue } } loadMesh(entityId: entityId, isNearBand: false) startedLoads += 1 diff --git a/Sources/UntoldEngine/Systems/MeshResourceManager.swift b/Sources/UntoldEngine/Systems/MeshResourceManager.swift index e6bc94fb..cdba77c3 100644 --- a/Sources/UntoldEngine/Systems/MeshResourceManager.swift +++ b/Sources/UntoldEngine/Systems/MeshResourceManager.swift @@ -95,13 +95,18 @@ public extension MeshResourceManager { } defer { finishInFlightLoad(url: url) } - // Load all meshes from file - let meshArrays = await Mesh.loadSceneMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - coordinateConversion: .autoDetect - ) + // Load all meshes from .untold file via native loader. + guard url.pathExtension.lowercased() == "untold" else { + Logger.logWarning(message: "[MeshResourceManager] Only .untold assets are supported. Ignoring '\(url.lastPathComponent)'.") + return + } + guard let runtimeAsset = try? NativeFormatLoader().loadAssetSync(from: url) else { return } + let meshArrays: [[Mesh]] = runtimeAsset.nodes + .filter { !$0.primitives.isEmpty } + .compactMap { node -> [Mesh]? in + let meshes = makeMeshes(from: node) + return meshes.isEmpty ? nil : meshes + } guard !meshArrays.isEmpty else { return } diff --git a/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift b/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift index a2428401..5e53ce9b 100644 --- a/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift +++ b/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift @@ -9,329 +9,72 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation -import MetalKit -import ModelIO // MARK: - Loader -/// Manages the out-of-core streaming CPU registry for large USD/USDZ assets. +/// Manages the out-of-core streaming CPU registry for .untold assets. /// -/// Large assets are registered as zero-GPU stub entities immediately. CPU-side MDLMesh data is -/// stored in `cpuMeshRegistry` keyed by child entity ID so `GeometryStreamingSystem` can upload -/// each mesh on demand — no disk re-read required on normal eviction/reload cycles. -/// -/// ## Warm / Cold Residency Lifecycle -/// -/// Each root asset starts **CPU-warm**: its `MDLAsset` and all child `MDLObject` buffers are -/// alive in `rootAssetRefs` / `cpuMeshRegistry`. Uploads read directly from RAM. -/// -/// Call `releaseWarmAsset(rootEntityId:)` to transition an asset to **CPU-cold**: the -/// `MDLAsset` and all child CPU buffers are released, freeing heap memory. The -/// `RootRehydrationContext` (URL + loading policy) stored at registration time is retained so -/// `GeometryStreamingSystem` can re-parse from disk transparently when a cold entity -/// re-enters streaming range. -/// -/// Cold re-hydration is serialized per root entity via `getOrCreateRehydrationTask`: exactly one -/// re-parse `Task` runs per root regardless of how many child entities concurrently detect the -/// cold state. Once re-parsing completes, `markAsWarm` restores the warm state. +/// Large .untold tiles are registered as zero-GPU stub entities. CPU-side RuntimeAssetNode data +/// is stored in `cpuRuntimeRegistry` keyed by child entity ID so `GeometryStreamingSystem` can +/// upload each stub on demand — no disk re-read required on normal eviction/reload cycles. /// /// ## Integration -/// - `setEntityMeshAsync()` registers stubs, stores CPU entries, and calls -/// `storeRootRehydrationContext` for large assets. -/// - `GeometryStreamingSystem.loadMeshAsync()` retrieves CPU entries via `retrieveCPUMesh(for:)`; -/// if the asset is cold it calls `rehydrateColdAsset` to re-parse from disk. -/// - `UntoldEngine.swift` calls `tick()` each frame (no-op; retained for API compatibility). +/// - `setEntityMeshAsync()` registers stubs and stores CPURuntimeEntry for OCC-routed tiles. +/// - `GeometryStreamingSystem.loadMeshAsync()` retrieves entries via `retrieveCPURuntimeEntry(for:)`. /// - Call `removeOutOfCoreAsset(rootEntityId:)` when destroying a root entity. -/// - Call `releaseWarmAsset(rootEntityId:)` to free CPU-heap memory while keeping the -/// entity registered so it can be re-hydrated on demand. +/// - Call `cancelAll()` during scene resets or test teardown. public final class ProgressiveAssetLoader: @unchecked Sendable { public static let shared = ProgressiveAssetLoader() - // MARK: Configuration - - /// File-size threshold (bytes) that routes assets to the out-of-core stub path. - /// - /// Assets whose on-disk size exceeds this value are registered as stub entities with - /// no GPU allocation. GeometryStreamingSystem uploads each stub from CPU RAM on demand. - /// Assets at or below use the immediate fast path (all meshes registered in one pass). - /// - /// Default: 50 MB. Adjust based on the target device's GPU memory budget. - /// - /// - Note: Deprecated. Asset classification is now handled by the two-stage admission gate - /// in `RegistrationSystem.setEntityMeshAsync` (Stage 1: 20× file-size expansion vs 50% - /// physical RAM; Stage 2: `AssetProfiler.profile` geometry bytes vs 75% physical RAM) - /// and by `AssetProfiler.classifyPolicy` for the `.auto` streaming policy. This property - /// is retained only for call-site compatibility and has no effect on admission decisions. - @available(*, deprecated, message: "No longer used for admission decisions. The two-stage gate in setEntityMeshAsync and AssetProfiler.classifyPolicy replace this threshold. Safe to remove from call sites.") - public var fileSizeThresholdBytes: Int = 50 * 1024 * 1024 // 50 MB - - /// Minimum leaf-mesh count that triggers the out-of-core stub path regardless of file size. - /// - /// A small USDZ (e.g. 18 MB) with 200+ objects would bypass the file-size threshold - /// and never get out-of-core treatment. This count-based trigger catches those cases. - /// Default: 50 meshes. Set to `Int.max` to disable count-based triggering. - /// - /// - Note: Deprecated. Asset classification is now handled by the two-stage admission gate - /// in `RegistrationSystem.setEntityMeshAsync` and by `AssetProfiler.classifyPolicy`. - /// This property is retained only for call-site compatibility and has no effect on - /// classification decisions. - @available(*, deprecated, message: "No longer used for classification. AssetProfiler.classifyPolicy and the two-stage admission gate replace this threshold. Safe to remove from call sites.") - public var outOfCoreObjectCountThreshold: Int = 50 - - /// Set to `false` to skip all texture loading during mesh upload. - /// Useful for testing geometry-only throughput without the texture decompression overhead. - public var textureLoadingEnabled: Bool = true - // MARK: State private let lock = NSLock() - // MARK: Out-of-Core CPU Registry + // MARK: - .untold OCC Registry - /// All context needed to upload one MDLMesh leaf to Metal without re-reading the USDZ. + /// All context needed to upload one .untold mesh node to Metal without re-reading the file. /// - /// Stored in `cpuMeshRegistry` keyed by child entity ID. The registry entry persists - /// across eviction/reload cycles so GeometryStreamingSystem can re-upload the mesh from - /// CPU RAM whenever the entity re-enters streaming range — no disk I/O required. - struct CPUMeshEntry: @unchecked Sendable { - let object: MDLObject - let vertexDescriptor: MDLVertexDescriptor - let textureLoader: TextureLoader - let device: MTLDevice + /// RuntimeAssetNode is a value type whose vertexData/indexData are self-contained Data blobs + /// — no parent asset reference is needed to keep the buffers alive. Stored in + /// cpuRuntimeRegistry keyed by child entity ID. + struct CPURuntimeEntry { + let node: RuntimeAssetNode let url: URL - let filename: String - let withExtension: String - /// Pre-computed unique name for this entity: "\(parentName)#\(index)". let uniqueAssetName: String - /// Estimated GPU memory (bytes) for pre-emptive budget reservation. - /// Computed from MDLMesh vertex/index counts at stub registration time — no disk I/O. let estimatedGPUBytes: Int - /// The loading policy selected at classification time (computed by AssetProfiler - /// for .auto, or derived from the caller's MeshStreamingPolicy for .outOfCore / - /// .immediate). This is immutable classification-time intent, not mutable runtime - /// state. uploadFromCPUEntry reads this to decide geometry and texture upload behaviour. let residencyPolicy: AssetLoadingPolicy } - /// Immutable context needed to re-parse a cold root asset from disk. - /// - /// Stored at stub-registration time in `rootRehydrationContexts`. Survives `releaseWarmAsset` - /// so `GeometryStreamingSystem` can re-parse the USDZ and rebuild CPU entries without - /// any information from the caller. - struct RootRehydrationContext: @unchecked Sendable { - let url: URL - let loadingPolicy: AssetLoadingPolicy - } - - /// CPU-resident mesh data keyed by child entity ID. - private var cpuMeshRegistry: [EntityID: CPUMeshEntry] = [:] - - /// CPU-resident LOD mesh data keyed by LOD group entity ID → LOD index. - /// - /// Used exclusively by the LOD+OOC path where a single entity holds a `LODComponent` - /// whose levels are each backed by a separate `CPUMeshEntry`. Unlike `cpuMeshRegistry` - /// (one entry per stub entity), this stores N entries per entity — one per LOD level. - private var cpuLODRegistry: [EntityID: [Int: CPUMeshEntry]] = [:] - - /// MDLAsset references kept alive per root entity so the MDLMeshBufferDataAllocator - /// that backs all child MDLMesh CPU buffers is not prematurely released. - private var rootAssetRefs: [EntityID: MDLAsset] = [:] + /// CPU-resident .untold runtime node data keyed by child entity ID. + private var cpuRuntimeRegistry: [EntityID: CPURuntimeEntry] = [:] /// Child entity IDs grouped by root entity ID — used to bulk-remove CPU entries /// when the root entity is destroyed. private var rootEntityChildren: [EntityID: [EntityID]] = [:] - /// Per-asset NSLocks that serialize texture hydration across concurrent streaming uploads. - /// - /// MDLAsset is not thread-safe. Two Tasks uploading different meshes from the same asset - /// simultaneously can race during `loadTextures()` or `texelDataWithTopLeftOrigin`. One lock - /// per root entity ensures only one mesh from a given asset loads textures at a time. - private var assetTextureLocks: [EntityID: NSLock] = [:] - - /// Tracks which root assets have already had `loadTextures()` called on their MDLAsset. - /// After the first upload from an asset triggers deferred texture loading, subsequent - /// uploads from the same asset skip the call. - private var assetTexturesLoaded: Set = [] - - /// Root entity IDs whose background prewarm task is currently executing `loadTextures()`. - /// Entities belonging to a root in this set are deferred by the scheduler: dispatching - /// them while the prewarm holds the per-asset texture lock would block the entire first - /// batch for the full remaining prewarm duration (~1-2 s). Slots stay free until the - /// prewarm releases the lock, then the burst fires with lockWait ≈ 0. - private var activePrewarmRoots: Set = [] - - // MARK: Warm / Cold Residency State - - /// Root entity IDs whose CPU data (MDLAsset + cpuMeshRegistry entries) has been released. - /// Warm roots are absent from this set. Cold roots are re-hydratable from `rootRehydrationContexts`. - private var coldRoots: Set = [] - - /// Rehydration context (URL + policy) keyed by root entity ID. - /// Populated at stub-registration time; survives `releaseWarmAsset`. - private var rootRehydrationContexts: [EntityID: RootRehydrationContext] = [:] - - /// In-flight re-parse tasks keyed by root entity ID. - /// `getOrCreateRehydrationTask` ensures at most one task runs per root concurrently. - private var coldRehydrationTasks: [EntityID: Task] = [:] - - func storeCPUMesh(_ entry: CPUMeshEntry, for entityId: EntityID) { - lock.lock() - cpuMeshRegistry[entityId] = entry - lock.unlock() - } - - func retrieveCPUMesh(for entityId: EntityID) -> CPUMeshEntry? { - lock.lock() - defer { lock.unlock() } - return cpuMeshRegistry[entityId] - } - - func removeCPUMesh(for entityId: EntityID) { - lock.lock() - cpuMeshRegistry.removeValue(forKey: entityId) - lock.unlock() - } - - // MARK: LOD CPU Registry + // MARK: - Registry API - /// Store the CPU mesh entry for one LOD level of a LOD group entity. - func storeCPULODMesh(_ entry: CPUMeshEntry, for entityId: EntityID, lodIndex: Int) { + func storeCPURuntimeEntry(_ entry: CPURuntimeEntry, for entityId: EntityID) { lock.lock() - if cpuLODRegistry[entityId] == nil { - cpuLODRegistry[entityId] = [:] - } - cpuLODRegistry[entityId]![lodIndex] = entry + cpuRuntimeRegistry[entityId] = entry lock.unlock() } - /// Retrieve the CPU mesh entry for one LOD level of a LOD group entity. - func retrieveCPULODMesh(for entityId: EntityID, lodIndex: Int) -> CPUMeshEntry? { - lock.lock() - defer { lock.unlock() } - return cpuLODRegistry[entityId]?[lodIndex] - } - - /// Retrieve all LOD-level CPU entries for a LOD group entity (keyed by LOD index). - func retrieveAllCPULODMeshes(for entityId: EntityID) -> [Int: CPUMeshEntry]? { - lock.lock() - defer { lock.unlock() } - return cpuLODRegistry[entityId] - } - - /// Returns `true` if the entity has at least one CPU LOD entry (i.e. was registered via the LOD+OOC path). - func hasCPULODData(for entityId: EntityID) -> Bool { + func retrieveCPURuntimeEntry(for entityId: EntityID) -> CPURuntimeEntry? { lock.lock() defer { lock.unlock() } - return !(cpuLODRegistry[entityId]?.isEmpty ?? true) - } - - /// Remove all CPU LOD entries for a LOD group entity (called on entity destruction or cold-release). - func removeCPULODEntry(for entityId: EntityID) { - lock.lock() - cpuLODRegistry.removeValue(forKey: entityId) - lock.unlock() - } - - func storeAsset(_ asset: MDLAsset, for rootEntityId: EntityID) { - lock.lock() - rootAssetRefs[rootEntityId] = asset - assetTextureLocks[rootEntityId] = NSLock() - lock.unlock() - // Kick off a background pre-warm so loadTextures() completes before any mesh - // enters streaming range. This moves the first-texture penalty off the critical - // upload path — the per-asset lock ensures at-most-once execution. - prewarmTexturesAsync(for: rootEntityId) + return cpuRuntimeRegistry[entityId] } - /// Returns `true` while the background prewarm task for `rootEntityId` is running. - /// - /// The scheduler uses this to defer dispatching entities for this root until the prewarm - /// releases the per-asset texture lock. Once it returns `false`, the next burst tick - /// dispatches the full first batch with lockWait ≈ 0. - func isPrewarmActive(for rootEntityId: EntityID) -> Bool { + func hasCPURuntimeData(for entityId: EntityID) -> Bool { lock.lock() defer { lock.unlock() } - return activePrewarmRoots.contains(rootEntityId) - } - - /// Fire a background task to call `loadTextures()` on this root asset's MDLAsset. - /// - /// Runs at user-initiated priority so it completes quickly before meshes enter range. - /// `ensureTexturesLoaded` is idempotent — if the upload path races and calls it first, - /// the pre-warm becomes a no-op, and vice versa. The per-asset texture lock prevents - /// both from running `loadTextures()` simultaneously. - /// Marks `activePrewarmRoots` while running so the scheduler can defer dispatch. - private func clearPrewarmActive(for rootEntityId: EntityID) { - lock.lock() - activePrewarmRoots.remove(rootEntityId) - lock.unlock() - } - - private func prewarmTexturesAsync(for rootEntityId: EntityID) { - guard textureLoadingEnabled else { return } - lock.lock() - activePrewarmRoots.insert(rootEntityId) - lock.unlock() - Task.detached(priority: .userInitiated) { - ProgressiveAssetLoader.shared.acquireAssetTextureLock(for: rootEntityId) - ProgressiveAssetLoader.shared.ensureTexturesLoaded(for: rootEntityId) - ProgressiveAssetLoader.shared.releaseAssetTextureLock(for: rootEntityId) - ProgressiveAssetLoader.shared.clearPrewarmActive(for: rootEntityId) - } - } - - /// Acquire the per-asset texture-load lock before calling makeMeshesFromCPUBuffers. - /// Returns immediately if no asset is registered for this root (e.g. non-out-of-core entity). - func acquireAssetTextureLock(for rootEntityId: EntityID) { - lock.lock() - let assetLock = assetTextureLocks[rootEntityId] - lock.unlock() - assetLock?.lock() + return cpuRuntimeRegistry[entityId] != nil } - /// Release the per-asset texture-load lock after makeMeshesFromCPUBuffers returns. - func releaseAssetTextureLock(for rootEntityId: EntityID) { - lock.lock() - let assetLock = assetTextureLocks[rootEntityId] - lock.unlock() - assetLock?.unlock() - } - - /// Call `loadTextures()` on the MDLAsset for `rootEntityId` if it has not been called yet. - /// - /// **Must be called while the per-asset texture lock is held** (i.e. between - /// `acquireAssetTextureLock` and `releaseAssetTextureLock`) to prevent two concurrent - /// uploads from both seeing `texturesLoaded == false` and both calling `loadTextures()`. - /// - /// This defers the texture decompression from parse time (where it could OOM-kill the - /// process) to first-upload time, where the app is already interactive and the RAM budget - /// is more predictable. Subsequent uploads from the same asset skip the call entirely. - func ensureTexturesLoaded(for rootEntityId: EntityID) { - lock.lock() - let alreadyLoaded = assetTexturesLoaded.contains(rootEntityId) - let asset = rootAssetRefs[rootEntityId] - lock.unlock() - - guard !alreadyLoaded, let asset else { return } - guard textureLoadingEnabled else { return } - - Logger.log( - message: "[OutOfCore] Deferred loadTextures() for root entity \(rootEntityId) — loading textures at first upload", - category: LogCategory.oocStatus.rawValue - ) - // [Instrumentation] Time the first-texture penalty: loadTextures() is only called - // once per asset, but it decodes all embedded textures synchronously. If this is - // large it confirms the first-texture setup cost as a dominant bottleneck. - let loadTexturesStart = CFAbsoluteTimeGetCurrent() - asset.loadTextures() - let loadTexturesMs = (CFAbsoluteTimeGetCurrent() - loadTexturesStart) * 1000.0 - Logger.log( - message: "[OOC-Timing] Root \(rootEntityId): loadTextures() first-texture penalty=\(String(format: "%.1f", loadTexturesMs))ms", - category: LogCategory.oocTiming.rawValue - ) - + func removeCPURuntimeEntry(for entityId: EntityID) { lock.lock() - assetTexturesLoaded.insert(rootEntityId) + cpuRuntimeRegistry.removeValue(forKey: entityId) lock.unlock() } @@ -341,125 +84,26 @@ public final class ProgressiveAssetLoader: @unchecked Sendable { lock.unlock() } - /// Store the rehydration context for a root entity. - /// Call this once at stub-registration time (after `storeAsset` and `registerChildren`). - func storeRootRehydrationContext(url: URL, policy: AssetLoadingPolicy, for rootEntityId: EntityID) { - lock.lock() - rootRehydrationContexts[rootEntityId] = RootRehydrationContext(url: url, loadingPolicy: policy) - lock.unlock() - } - - // MARK: Warm / Cold Lifecycle - - /// 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. - /// - /// - Note: This does NOT destroy ECS entities or GPU resources. It only frees the CPU-side - /// MDLAsset and CPU buffers. GPU-resident meshes (if any) remain until eviction. - public func releaseWarmAsset(rootEntityId: EntityID) { - lock.lock() - let children = rootEntityChildren[rootEntityId] ?? [] - rootAssetRefs.removeValue(forKey: rootEntityId) - assetTextureLocks.removeValue(forKey: rootEntityId) - assetTexturesLoaded.remove(rootEntityId) - for childId in children { - cpuMeshRegistry.removeValue(forKey: childId) - cpuLODRegistry.removeValue(forKey: childId) - } - coldRoots.insert(rootEntityId) - lock.unlock() - Logger.log( - message: "[OutOfCore] Released warm CPU data for root \(rootEntityId) (\(children.count) children) — asset is now cold", - category: LogCategory.oocStatus.rawValue - ) - } - - /// Returns `true` if the root entity is CPU-cold (warm assets were released via `releaseWarmAsset`). - func isColdRoot(_ rootEntityId: EntityID) -> Bool { - lock.lock() - defer { lock.unlock() } - return coldRoots.contains(rootEntityId) - } - - /// Returns the rehydration context for a root entity, or `nil` if none is registered. - func rehydrationContext(for rootEntityId: EntityID) -> RootRehydrationContext? { - lock.lock() - defer { lock.unlock() } - return rootRehydrationContexts[rootEntityId] - } - - /// Returns the child entity IDs for a root entity (in registration order). func getChildren(for rootEntityId: EntityID) -> [EntityID] { lock.lock() defer { lock.unlock() } return rootEntityChildren[rootEntityId] ?? [] } - /// Return the existing in-flight rehydration task for `rootEntityId`, or create and - /// store a new one using `factory`. Exactly one task runs per root at a time. - func getOrCreateRehydrationTask( - for rootEntityId: EntityID, - factory: () -> Task - ) -> Task { - lock.lock() - defer { lock.unlock() } - if let existing = coldRehydrationTasks[rootEntityId] { - return existing - } - let task = factory() - coldRehydrationTasks[rootEntityId] = task - return task - } - - /// Restore a root entity to warm state after successful re-hydration. - /// Removes it from `coldRoots` and clears the completed rehydration task. - func markAsWarm(rootEntityId: EntityID) { - lock.lock() - coldRoots.remove(rootEntityId) - coldRehydrationTasks.removeValue(forKey: rootEntityId) - lock.unlock() - } - - /// Remove the in-flight rehydration task for `rootEntityId` (e.g. after failure). - func clearRehydrationTask(for rootEntityId: EntityID) { - lock.lock() - coldRehydrationTasks.removeValue(forKey: rootEntityId) - lock.unlock() - } + // MARK: - Lifecycle - /// Release all CPU mesh entries and the MDLAsset for a root entity. + /// Release all CPU entries for a root entity. /// Call this when the root entity is destroyed to free CPU-heap geometry data. public func removeOutOfCoreAsset(rootEntityId: EntityID) { lock.lock() let children = rootEntityChildren.removeValue(forKey: rootEntityId) ?? [] - rootAssetRefs.removeValue(forKey: rootEntityId) - assetTextureLocks.removeValue(forKey: rootEntityId) - assetTexturesLoaded.remove(rootEntityId) for childId in children { - cpuMeshRegistry.removeValue(forKey: childId) - cpuLODRegistry.removeValue(forKey: childId) + cpuRuntimeRegistry.removeValue(forKey: childId) } - // Clear warm/cold lifecycle state and prewarm tracking. - coldRoots.remove(rootEntityId) - rootRehydrationContexts.removeValue(forKey: rootEntityId) - activePrewarmRoots.remove(rootEntityId) - let task = coldRehydrationTasks.removeValue(forKey: rootEntityId) lock.unlock() - task?.cancel() if !children.isEmpty { Logger.log( - message: "[OutOfCore] Released CPU mesh data for \(children.count) entities (root \(rootEntityId))", + message: "[OutOfCore] Released CPU data for \(children.count) entities (root \(rootEntityId))", category: LogCategory.oocStatus.rawValue ) } @@ -469,32 +113,19 @@ public final class ProgressiveAssetLoader: @unchecked Sendable { // MARK: Per-Frame Tick - /// No-op stub retained for call-site compatibility (UntoldEngine.swift, UntoldEngineXR.swift). - /// The out-of-core path registers all stubs synchronously in setEntityMeshAsync and drives - /// GPU uploads via GeometryStreamingSystem — no per-frame job processing is needed here. + /// No-op stub retained for call-site compatibility. public func tick() {} // MARK: Cancellation - /// Release all out-of-core CPU mesh entries and the MDLAsset for every root entity. - /// Call this during scene resets or test teardown. + /// Release all CPU entries. Call this during scene resets or test teardown. public func cancelAll() { lock.lock() - cpuMeshRegistry.removeAll() - cpuLODRegistry.removeAll() - rootAssetRefs.removeAll() + cpuRuntimeRegistry.removeAll() rootEntityChildren.removeAll() - assetTextureLocks.removeAll() - assetTexturesLoaded.removeAll() - coldRoots.removeAll() - rootRehydrationContexts.removeAll() - activePrewarmRoots.removeAll() - let tasks = Array(coldRehydrationTasks.values) - coldRehydrationTasks.removeAll() lock.unlock() - tasks.forEach { $0.cancel() } Logger.log( - message: "[OutOfCore] Released all CPU mesh data (cancelAll)", + message: "[OutOfCore] Released all CPU data (cancelAll)", category: LogCategory.oocStatus.rawValue ) } diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index fda0ee13..ab8db3e8 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -12,7 +12,6 @@ import CShaderTypes import Foundation import MetalKit -@preconcurrency import ModelIO @inline(__always) private func enforceRegistrationMainActor() { @@ -50,15 +49,10 @@ private final class ResumeOnce: @unchecked Sendable { } } -private struct SendableMDLAssetBox: @unchecked Sendable { - let asset: MDLAsset -} - private final class RegistrationRuntimeState: @unchecked Sendable { let lock = NSLock() var pendingDestroyCompletions: [() -> Void] = [] var componentCleanupHandlersRegistered = false - var skeletonCache: [URL: MDLSkeleton?] = [:] var customComponentEncoderMap: [ObjectIdentifier: (EntityID) -> Data?] = [:] var customComponentDecoderMap: [String: (EntityID, Data) -> Void] = [:] var customComponentTypeNameById: [ObjectIdentifier: String] = [:] @@ -369,71 +363,6 @@ private func splitMeshGroupBySourceName(_ meshGroup: [Mesh]) -> [String: [Mesh]] return grouped } -private func detectImportedLODGroups(from meshGroups: [[Mesh]]) -> [ImportedLODGroupCandidate]? { - var meshesBySourceName: [String: [Mesh]] = [:] - - for meshGroup in meshGroups { - let groupedBySourceName = splitMeshGroupBySourceName(meshGroup) - for (sourceName, sourceMeshes) in groupedBySourceName { - meshesBySourceName[sourceName, default: []].append(contentsOf: sourceMeshes) - } - } - - let detectionResult = detectImportedLODGroups(fromSourceNames: Array(meshesBySourceName.keys)) - if !detectionResult.ambiguousBaseNames.isEmpty { - let baseNames = detectionResult.ambiguousBaseNames.sorted().joined(separator: ", ") - Logger.logWarning(message: "Ambiguous imported LOD groups skipped: \(baseNames)") - } - - var detectedGroups: [ImportedLODGroupCandidate] = [] - detectedGroups.reserveCapacity(detectionResult.groups.count) - - for group in detectionResult.groups { - if group.levels.contains(where: { $0.lodIndex == 0 }) == false { - Logger.logWarning(message: "Imported LOD group '\(group.baseName)' is missing LOD0.") - } - - let missingIndices = missingLODIndices(for: group.levels) - if !missingIndices.isEmpty { - let indices = missingIndices.map(String.init).joined(separator: ", ") - Logger.logWarning(message: "Imported LOD group '\(group.baseName)' has sparse levels. Missing: \(indices)") - } - - var meshLevels: [ImportedLODLevelCandidate] = [] - meshLevels.reserveCapacity(group.levels.count) - - for level in group.levels { - guard let sourceMeshes = meshesBySourceName[level.sourceName], !sourceMeshes.isEmpty else { - continue - } - meshLevels.append( - ImportedLODLevelCandidate( - lodIndex: level.lodIndex, - sourceName: level.sourceName, - meshes: sourceMeshes - ) - ) - } - - guard meshLevels.count >= 2 else { - continue - } - - detectedGroups.append( - ImportedLODGroupCandidate( - baseName: group.baseName, - levels: meshLevels.sorted { $0.lodIndex < $1.lodIndex } - ) - ) - } - - guard !detectedGroups.isEmpty else { - return nil - } - - return detectedGroups.sorted { $0.baseName < $1.baseName } -} - private func meshesWithDefaultSkin(_ meshes: [Mesh]) -> [Mesh] { var updatedMeshes = meshes let defaultSkin = Skin() @@ -548,231 +477,6 @@ private func applyImportedTransformFromMeshGroup(_ meshGroup: [Mesh], to entityI } @discardableResult -private func tryRegisterImportedLODGroup( - entityId: EntityID, - url: URL, - filename: String, - withExtension: String, - nonEmptyMeshes: [[Mesh]] -) -> Bool { - guard let importedLODGroups = detectImportedLODGroups(from: nonEmptyMeshes) else { - return false - } - - if importedLODGroups.count == 1, let importedLOD = importedLODGroups.first { - let lodLevels = buildImportedLODLevels(from: importedLOD, url: url) - guard let activeLODIndex = lodLevels.firstIndex(where: { !$0.mesh.isEmpty }) else { - return false - } - - if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: entityId) - } - - if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: entityId) - } - - let activeLOD = lodLevels[activeLODIndex] - let activeAssetName = activeLOD.assetName ?? importedLOD.baseName - associateMeshesToEntity(entityId: entityId, meshes: activeLOD.mesh) - registerRenderComponent(entityId: entityId, meshes: activeLOD.mesh, url: url, assetName: activeAssetName) - configureLODComponent(entityId: entityId, lodLevels: lodLevels, activeLODIndex: activeLODIndex) - - setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) - Logger.log(message: "✅ Auto-detected imported LOD group '\(importedLOD.baseName)' with \(importedLOD.levels.count) levels") - return true - } - - // Multiple LOD families in one USDZ: create asset root + one child entity per base group. - let assetInstanceComp = AssetInstanceComponent( - assetURL: url, - assetName: filename, - importMode: "preserveHierarchy", - rootPrimPath: nil - ) - registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) - if let instanceComp = scene.get(component: AssetInstanceComponent.self, for: entityId) { - instanceComp.assetURL = assetInstanceComp.assetURL - instanceComp.assetName = assetInstanceComp.assetName - instanceComp.importMode = assetInstanceComp.importMode - instanceComp.rootPrimPath = assetInstanceComp.rootPrimPath - } - - var createdChildren = 0 - - for (index, importedLOD) in importedLODGroups.enumerated() { - let lodLevels = buildImportedLODLevels(from: importedLOD, url: url) - guard let activeLODIndex = lodLevels.firstIndex(where: { !$0.mesh.isEmpty }) else { - continue - } - - let childEntityId = createEntity() - if hasComponent(entityId: childEntityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: childEntityId) - } - if hasComponent(entityId: childEntityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: childEntityId) - } - - let activeLOD = lodLevels[activeLODIndex] - applyImportedTransformFromMeshGroup(activeLOD.mesh, to: childEntityId) - let activeAssetName = activeLOD.assetName ?? importedLOD.baseName - associateMeshesToEntity(entityId: childEntityId, meshes: activeLOD.mesh) - registerRenderComponent(entityId: childEntityId, meshes: activeLOD.mesh, url: url, assetName: activeAssetName) - configureLODComponent(entityId: childEntityId, lodLevels: lodLevels, activeLODIndex: activeLODIndex) - - setEntityName(entityId: childEntityId, name: importedLOD.baseName) - setParent(childId: childEntityId, parentId: entityId) - - let nodePath = generateStableNodePath(assetName: importedLOD.baseName, index: index) - let derivedComp = DerivedAssetNodeComponent(assetRootEntityId: entityId, nodePath: nodePath) - registerComponent(entityId: childEntityId, componentType: DerivedAssetNodeComponent.self) - if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: childEntityId) { - derived.assetRootEntityId = derivedComp.assetRootEntityId - derived.nodePath = derivedComp.nodePath - } - - setEntitySkeleton(entityId: childEntityId, filename: filename, withExtension: withExtension) - createdChildren += 1 - } - - guard createdChildren > 0 else { - return false - } - - Logger.log(message: "✅ Auto-detected imported LOD groups: \(createdChildren) entities created from \(importedLODGroups.count) LOD families") - return true -} - -private func setEntityMeshCommon( - entityId: EntityID, - filename: String, - withExtension: String, - flip _: Bool, - meshLoader: (URL) -> [[Mesh]], - entityName _: String?, - assetName: String? -) -> Bool { - guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { - handleError(.filenameNotFound, filename) - return false - } - - if url.pathExtension == "dae" { - handleError(.fileTypeNotSupported, url.pathExtension) - return false - } - - let meshes = meshLoader(url) - let supportsSkeletons = RuntimeAssetSource.infer(from: url).kind != .untold - - // Cache meshes for streaming system (so reloads don't require disk I/O) - MeshResourceManager.shared.cacheLoadedMeshes(url: url, meshArrays: meshes) - - if meshes.isEmpty { - handleError(.assetDataMissing, filename) - return false - } - - var nonEmptyMeshes = meshes.filter { !$0.isEmpty } - - if let assetNameExist = assetName { - if let matchedMesh = nonEmptyMeshes.first(where: { $0.first?.assetName == assetNameExist }) { - nonEmptyMeshes = [matchedMesh] - } else { - handleError(.assetDataMissing, "No mesh with asset name \(assetNameExist)") - return false - } - } - - if tryRegisterImportedLODGroup( - entityId: entityId, - url: url, - filename: filename, - withExtension: withExtension, - nonEmptyMeshes: nonEmptyMeshes - ) { - return true - } - - if nonEmptyMeshes.count == 1 { - let mesh = nonEmptyMeshes[0] - - if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: entityId) - } - - if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: entityId) - } - - associateMeshesToEntity(entityId: entityId, meshes: mesh) - registerRenderComponent(entityId: entityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - if supportsSkeletons { - setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) - } - - } else if nonEmptyMeshes.count > 1 { - // Multi-mesh asset: mark root as AssetInstance, children as DerivedAssetNode - let assetInstanceComp = AssetInstanceComponent( - assetURL: url, - assetName: assetName ?? filename, - importMode: "preserveHierarchy", - rootPrimPath: nil - ) - registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) - if let instanceComp = scene.get(component: AssetInstanceComponent.self, for: entityId) { - instanceComp.assetURL = assetInstanceComp.assetURL - instanceComp.assetName = assetInstanceComp.assetName - instanceComp.importMode = assetInstanceComp.importMode - instanceComp.rootPrimPath = assetInstanceComp.rootPrimPath - } - - for (index, mesh) in nonEmptyMeshes.enumerated() { - let childEntityId = createEntity() - - if hasComponent(entityId: childEntityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: childEntityId) - } - - if hasComponent(entityId: childEntityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: childEntityId) - } - - // Extract full transform (translation, rotation, scale) from mesh world space - // before RenderComponent registration. - if let firstMesh = mesh.first { - applyWorldTransform(firstMesh.worldSpace, to: childEntityId) - } - - associateMeshesToEntity(entityId: childEntityId, meshes: mesh) - - registerRenderComponent(entityId: childEntityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - - let meshAssetName = mesh.first!.assetName - setEntityName(entityId: childEntityId, name: meshAssetName) - - setParent(childId: childEntityId, parentId: entityId) - - // Tag as derived node with stable nodePath - let nodePath = generateStableNodePath(assetName: meshAssetName, index: index) - let derivedComp = DerivedAssetNodeComponent(assetRootEntityId: entityId, nodePath: nodePath) - registerComponent(entityId: childEntityId, componentType: DerivedAssetNodeComponent.self) - if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: childEntityId) { - derived.assetRootEntityId = derivedComp.assetRootEntityId - derived.nodePath = derivedComp.nodePath - } - - // look for any skeletons in asset - if supportsSkeletons { - setEntitySkeleton(entityId: childEntityId, filename: filename, withExtension: withExtension) - } - } - } - - return true -} private func loadUntoldRuntimeAsset(url: URL) -> RuntimeAsset? { do { @@ -848,7 +552,7 @@ private func ensureUntoldNodeComponents(entityId: EntityID) { } } -private func makeMeshes(from node: RuntimeAssetNode) -> [Mesh] { +func makeMeshes(from node: RuntimeAssetNode) -> [Mesh] { node.primitives.compactMap { primitive -> Mesh? in guard var mesh = Mesh.makeMesh(from: primitive, device: renderInfo.device) else { return nil @@ -859,6 +563,159 @@ private func makeMeshes(from node: RuntimeAssetNode) -> [Mesh] { } } +/// Register one RuntimeAssetNode as a zero-GPU OCC stub entity. +/// +/// Creates the ECS presence (transform, scenegraph, streaming component) with no GPU allocation. +/// GeometryStreamingSystem uploads via uploadFromRuntimeEntry when the entity enters streaming range. +/// +/// - Parameters: +/// - parentEntityId: The direct scene-graph parent (may be a container node, not always the asset root). +/// - rootEntityId: The asset root entity used for DerivedAssetNodeComponent tracking. +@discardableResult +private func registerUntoldProgressiveStubEntity( + node: RuntimeAssetNode, + index: Int, + uniqueAssetName: String, + parentEntityId: EntityID, + rootEntityId: EntityID, + url _: URL, + filename: String, + withExtension ext: String +) -> EntityID { + let childEntityId = createEntity() + + ensureUntoldNodeComponents(entityId: childEntityId) + + // Compute the local transform that will produce node.worldTransform after parenting. + // After setParent: childWorld = parentWorld × childLocal + // We want childWorld = node.worldTransform, so: + // childLocal = inverse(parentWorld) × node.worldTransform + // For tile geometry (parentWorld = identity) this equals node.worldTransform directly. + // For non-identity parents this prevents double-application of the parent transform. + let parentWorldTransform = scene.get(component: WorldTransformComponent.self, for: parentEntityId)?.space + ?? matrix_identity_float4x4 + let localTransform = simd_mul(parentWorldTransform.inverse, node.worldTransform) + applyLocalTransform(localTransform, to: childEntityId) + + if let local = scene.get(component: LocalTransformComponent.self, for: childEntityId) { + local.boundingBox = (min: node.localBounds.min, max: node.localBounds.max) + } + + setEntityName(entityId: childEntityId, name: uniqueAssetName) + setParent(childId: childEntityId, parentId: parentEntityId) + + // Register with the octree so GeometryStreamingSystem.update() finds this stub + // via queryNear. Without this, the stub is invisible to the streaming scheduler. + OctreeSystem.shared.registerEntity(childEntityId) + + let nodePath = generateStableNodePath(assetName: uniqueAssetName, index: index) + registerComponent(entityId: childEntityId, componentType: DerivedAssetNodeComponent.self) + if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: childEntityId) { + derived.assetRootEntityId = rootEntityId + derived.nodePath = nodePath + } + + registerComponent(entityId: childEntityId, componentType: StreamingComponent.self) + if let sc = scene.get(component: StreamingComponent.self, for: childEntityId) { + sc.assetFilename = filename + sc.assetExtension = ext + sc.assetName = uniqueAssetName + sc.state = .unloaded + // Placeholder radii — enableStreaming() (called by GeometryStreamingSystem via tile) sets real values. + sc.streamingRadius = Float.greatestFiniteMagnitude + sc.unloadRadius = Float.greatestFiniteMagnitude + } + + return childEntityId +} + +/// Register all renderable nodes in a .untold RuntimeAsset as OCC stub entities. +/// +/// Each node with primitives becomes a zero-GPU child stub with StreamingComponent(.unloaded) +/// and a CPURuntimeEntry in ProgressiveAssetLoader. Container nodes (no primitives) become +/// plain hierarchy entities. Renderable nodes are always created as CHILDREN of entityId +/// (never as entityId itself) so countOCCDescendants finds them correctly. +/// +/// Hierarchy is preserved: a renderable node whose parentID points to a container node is +/// parented to that container entity, not directly to entityId. +@discardableResult +private func registerUntoldRuntimeAssetOCC( + entityId: EntityID, + runtimeAsset: RuntimeAsset, + url: URL, + filename: String, + withExtension ext: String, + assetName _: String? +) -> Bool { + guard !runtimeAsset.nodes.isEmpty else { + handleError(.assetDataMissing, filename) + return false + } + + ensureUntoldNodeComponents(entityId: entityId) + applyLocalTransform(runtimeAsset.rootTransform, to: entityId) + + var entityByNodeID: [UInt32: EntityID] = [:] + var childIds: [EntityID] = [] + var index = 0 + + let residencyPolicy = AssetLoadingPolicy(geometry: .streaming, texture: .eager, source: .auto) + + for node in runtimeAsset.nodes { + if node.primitives.isEmpty { + // Container node — hierarchy entity only, no StreamingComponent. + let containerEntityId = createEntity() + ensureUntoldNodeComponents(entityId: containerEntityId) + applyLocalTransform(node.localTransform, to: containerEntityId) + setEntityName(entityId: containerEntityId, name: node.name) + let parentEntityId = node.parentID.flatMap { entityByNodeID[$0] } ?? entityId + setParent(childId: containerEntityId, parentId: parentEntityId) + entityByNodeID[node.id] = containerEntityId + + } else { + // Renderable node — always a CHILD OCC stub (never the root entity). + // This ensures countOCCDescendants finds it correctly even for single-node assets. + let uniqueName = runtimeAsset.nodes.filter { !$0.primitives.isEmpty }.count == 1 + ? node.name + : "\(node.name)#\(index)" + // Parent to the node's actual parent (container) if it has one; otherwise to entityId. + let parentEntityId = node.parentID.flatMap { entityByNodeID[$0] } ?? entityId + let childEntityId = registerUntoldProgressiveStubEntity( + node: node, + index: index, + uniqueAssetName: uniqueName, + parentEntityId: parentEntityId, + rootEntityId: entityId, + url: url, + filename: filename, + withExtension: ext + ) + + let estimatedBytes = node.primitives.reduce(0) { $0 + $1.estimatedGPUBytes } + let entry = ProgressiveAssetLoader.CPURuntimeEntry( + node: node, + url: url, + uniqueAssetName: uniqueName, + estimatedGPUBytes: estimatedBytes, + residencyPolicy: residencyPolicy + ) + ProgressiveAssetLoader.shared.storeCPURuntimeEntry(entry, for: childEntityId) + childIds.append(childEntityId) + entityByNodeID[node.id] = childEntityId + index += 1 + } + } + + ProgressiveAssetLoader.shared.registerChildren(childIds, for: entityId) + syncWorldTransformAndMarkOctreeDirty(entityId: entityId) + + Logger.log( + message: "[OutOfCore] '\(filename)': .untold → OCC stub registration (\(childIds.count) stubs)", + category: LogCategory.oocStatus.rawValue + ) + return true +} + @discardableResult private func registerUntoldNodePayload( entityId: EntityID, @@ -1043,6 +900,20 @@ private func registerUntoldRuntimeAsset( _ = registerUntoldNodePayload(entityId: targetEntityId, node: node, nodesByID: nodesByID, url: url) } + // Register animation clips embedded in the asset (e.g. redplayer.untold walk/run cycles). + // Resolve to the skinned descendant if the root is a container (skeleton hierarchy). + let animClips = runtimeAsset.animationClips + if !animClips.isEmpty { + let animTarget = resolveEntityForAnimationBinding(entityId: entityId) ?? entityId + if let animComp = ensureAnimationComponent(entityId: animTarget, errorEntityId: entityId) { + let registeredNames = registerRuntimeAnimationClips(animClips, preferredName: animClips.first?.name ?? "", to: animComp) + appendAnimationSourceURLIfNeeded(url, to: animComp) + if animComp.currentAnimation == nil, let first = registeredNames.first { + animComp.currentAnimation = animComp.animationClips[first] + } + } + } + // Propagate world transforms for the full hierarchy now that all nodes are // registered. setParent() calls syncWorldTransformAndMarkOctreeDirty on each // child, but at that point the root entity's worldTransformComponent.space has @@ -1061,963 +932,202 @@ func generateStableNodePath(assetName: String, index: Int) -> String { "Root/\(assetName)#\(index)" } -/// Register one MDLMesh leaf as an out-of-core stub entity. -/// -/// Creates the full ECS presence (transform, scenegraph, streaming component) with NO GPU -/// allocation. The `StreamingComponent` starts in `.unloaded` state with placeholder -/// radii (`Float.greatestFiniteMagnitude`) so the streaming system ignores the entity -/// until `enableStreaming()` is called and real radii are set. -/// -/// **Must be called from within an existing `withWorldMutationGate` block.** -/// The caller (setEntityMeshAsync) wraps the entire stub-registration loop in a single gate -/// acquisition rather than one gate per stub, avoiding N × acquire/release overhead for -/// assets with hundreds of mesh leaves. -/// -/// The caller is responsible for storing the MDLMesh in `ProgressiveAssetLoader.cpuMeshRegistry` -/// so `GeometryStreamingSystem.loadMeshAsync` can upload it from CPU when the entity enters range. -/// -/// - Returns: The newly created child `EntityID`. -@discardableResult -func registerProgressiveStubEntity( - mdlObject: MDLObject, - index: Int, - uniqueAssetName: String, - rootEntityId: EntityID, - url _: URL, - filename: String, - withExtension ext: String -) -> EntityID { - let childEntityId = createEntity() - - if hasComponent(entityId: childEntityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: childEntityId) - } - - if hasComponent(entityId: childEntityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: childEntityId) - } - - // Set world position from the MDLObject's composed transform. - // This is what the octree and distance calculations will use. - let worldTransform = composedWorldTransform(for: mdlObject) - applyWorldTransform(worldTransform, to: childEntityId) - - // Seed the bounding box from the MDLMesh so OctreeSystem and calculateDistance - // compute meaningful spatial extents even before the RenderComponent exists. - if let mdlMesh = mdlObject as? MDLMesh, - let local = scene.get(component: LocalTransformComponent.self, for: childEntityId) - { - local.boundingBox = (min: mdlMesh.boundingBox.minBounds, max: mdlMesh.boundingBox.maxBounds) - } - - setEntityName(entityId: childEntityId, name: uniqueAssetName) - setParent(childId: childEntityId, parentId: rootEntityId) - - // Stable identity for serialisation / scene graph lookup. - let nodePath = generateStableNodePath(assetName: uniqueAssetName, index: index) - registerComponent(entityId: childEntityId, componentType: DerivedAssetNodeComponent.self) - if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: childEntityId) { - derived.assetRootEntityId = rootEntityId - derived.nodePath = nodePath - } - - // StreamingComponent in .unloaded state — GPU resources will be created by - // GeometryStreamingSystem when the entity enters streamingRadius. - registerComponent(entityId: childEntityId, componentType: StreamingComponent.self) - if let sc = scene.get(component: StreamingComponent.self, for: childEntityId) { - sc.assetFilename = filename - sc.assetExtension = ext - sc.assetName = uniqueAssetName - sc.state = .unloaded - // Large placeholder radii: enableStreaming() sets the real values. - // This prevents the streaming system from immediately queueing a disk-based - // reload before the out-of-core CPU registry entry is in place. - sc.streamingRadius = Float.greatestFiniteMagnitude - sc.unloadRadius = Float.greatestFiniteMagnitude - } - - // Register with the octree so update() spatial queries can find this stub. - OctreeSystem.shared.registerEntity(childEntityId) - - return childEntityId -} - -/// Synchronously load and set an entity mesh on the calling thread. -/// -/// This API always uses the **immediate** path: all Metal resources are created in a single -/// pass before the function returns. It does not support out-of-core stub registration or -/// distance-based streaming — the mesh is permanently GPU-resident after this call. -/// -/// For large assets or any asset that should benefit from distance-based streaming and -/// eviction, use `setEntityMeshAsync(streamingPolicy:)` instead. -public func setEntityMesh(entityId: EntityID, filename: String, withExtension: String, assetName: String? = nil, flip: Bool = true, coordinateConversion: CoordinateSystemConversion = .autoDetect) { - if let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil), - RuntimeAssetSource.infer(from: url).kind == .untold - { - if let runtimeAsset = loadUntoldRuntimeAsset(url: url), - registerUntoldRuntimeAsset( - entityId: entityId, - runtimeAsset: runtimeAsset, - url: url, - filename: filename, - withExtension: withExtension, - assetName: assetName - ) - { - return - } - - loadFallbackMesh(entityId: entityId, filename: filename) - return - } - - _ = setEntityMeshCommon( - entityId: entityId, - filename: filename, - withExtension: withExtension, - flip: flip, - meshLoader: { url in - Mesh.loadSceneMeshes(url: url, vertexDescriptor: vertexDescriptor.model, device: renderInfo.device, coordinateConversion: coordinateConversion) - }, - entityName: nil, - assetName: assetName - ) -} - -/// Controls how `setEntityMeshAsync` manages GPU residency for a loaded asset. -public enum MeshStreamingPolicy: Sendable { - /// Automatic: uses `ProgressiveAssetLoader.fileSizeThresholdBytes` and - /// `outOfCoreObjectCountThreshold` to decide. Large or many-object assets - /// go out-of-core; small assets upload directly. Default. - case auto - - /// Always register leaf meshes as `.unloaded` stub entities. The streaming - /// system uploads each mesh to the GPU when the camera enters `streamingRadius` - /// and evicts it when the camera moves beyond `unloadRadius`. - /// - /// The completion callback fires immediately after stub registration — no GPU - /// work happens at load time. **You must call `enableStreaming(entityId:streamingRadius:unloadRadius:)` - /// inside the completion block** so the streaming system knows the real radii. - case outOfCore - - /// Always upload directly to the GPU in a single pass. The mesh is permanently - /// resident and is never evicted by the streaming system. Use for small assets - /// that must be visible without any streaming delay (e.g. character, weapon, HUD). - case immediate -} - -/// Asynchronously load and set entity mesh without blocking the main thread -public func setEntityMeshAsync( - entityId: EntityID, - filename: String, - withExtension: String, - assetName: String? = nil, - flip _: Bool = true, - coordinateConversion: CoordinateSystemConversion = .autoDetect, - streamingPolicy: MeshStreamingPolicy = .auto, - blockRenderLoop: Bool = true, - completion: ((Bool) -> Void)? = nil -) { - let completionBox = completion.map { BoolCompletionBox(callback: $0) } - - Task { - // Mark as loading. Secondary assets (LOD levels, HLODs) pass blockRenderLoop:false — - // the gate is opened and immediately closed so the render loop is never stalled - // waiting for supplementary geometry. All downstream finishLoading calls are - // idempotent no-ops once the entity is already removed from the loading set. - await AssetLoadingState.shared.startLoading(entityId: entityId, filename: filename) - if !blockRenderLoop { - 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) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return - } - - if url.pathExtension == "dae" { - handleError(.fileTypeNotSupported, url.pathExtension) - loadFallbackMesh(entityId: entityId, filename: filename) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return - } - - if RuntimeAssetSource.infer(from: url).kind == .untold { - if streamingPolicy != .immediate { - Logger.logWarning(message: "[Untold] '.untold' assets currently use the immediate full-load path. Ignoring streaming policy '\(streamingPolicy)'.") - } - - let didLoad: Bool - if let runtimeAsset = loadUntoldRuntimeAsset(url: url) { - didLoad = registerUntoldRuntimeAsset( - entityId: entityId, - runtimeAsset: runtimeAsset, - url: url, - filename: filename, - withExtension: withExtension, - assetName: assetName - ) - } else { - didLoad = false - } - - if !didLoad { - loadFallbackMesh(entityId: entityId, filename: filename) - } - - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(didLoad) - return - } - - // MARK: Out-of-core / small-file routing - - // All assets parse with a CPU-only allocator to avoid the GPU memory spike caused - // by MTKMeshBufferAllocator pre-allocating Metal buffers for the entire scene. - // - // Two-stage admission gate (V1): - // Stage 1 (pre-parse): coarse file-size × expansion multiplier check — rejects - // obviously unsafe assets before Model I/O touches the file. - // Stage 2 (post-parse): accurate profiler-based check after parse completes — - // the final authority. Note: Stage 2 cannot prevent the - // parse-time RAM spike; it prevents all downstream work - // (stub registration, MDLAsset retention, CPU registry storage). - // - // If both gates pass, large assets register every leaf mesh immediately as a stub - // entity (zero-GPU). CPU-side MDLMesh data is stored in ProgressiveAssetLoader so - // GeometryStreamingSystem can upload each stub on demand without a disk re-read. - // Small assets create all Metal resources right here in a single pass. - if assetName == nil { - // ── Stage 1: Pre-parse admission gate ───────────────────────────────────── - // Compute file size before parseAssetAsync so the gate fires before Model I/O - // allocates CPU heap for all mesh buffers. - // - // Expansion factor: 20× — conservative upper bound for USDZ geometry - // decompression. Real-world worst case is ~55× (a 159 MB city USDZ - // expanding to ~8858 MB of geometry). 20× catches obvious outliers without - // rejecting normal-sized files. - // - // Three-zone model: - // Safe zone projectedCPU ≤ 50% RAM — allow, no log - // Soft zone projectedCPU > 50% AND < 75% RAM - // → log warning, allow parse, delegate to Stage 2 - // → expected for texture-heavy USDZs: compressed texture bytes - // in a USDZ do not expand at parse time (MDLMeshBufferData- - // Allocator only decompresses geometry; textures are decoded - // lazily at first-upload time via ensureTexturesLoaded). - // Stage 2 is the accurate authority for these borderline cases. - // Hard reject projectedCPU ≥ 75% RAM — reject before parse, load fallback - // → geometry expansion of this magnitude would risk an OOM kill - // before Stage 2 can even run. - // - // Known gap: the assetName != nil path (Mesh.loadSceneMeshesAsync) is not - // guarded. That path is only used for named-mesh lookups and is not expected - // to be called with large assets in normal production use. - // - // Future refinement: a lightweight USDZ ZIP central-directory scan could - // separate texture-entry bytes from scene-entry bytes before parsing and apply - // the 20× multiplier only to the scene portion, eliminating soft-zone false - // positives for texture-heavy assets entirely. Validate the soft-zone model - // on real assets before adding that complexity. - let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int ?? 0 - if fileSizeBytes > 0 { - let physicalMemory = Int(ProcessInfo.processInfo.physicalMemory) - let softZoneThreshold = Int(Double(physicalMemory) * 0.50) // soft zone starts here - let hardRejectThreshold = Int(Double(physicalMemory) * 0.75) // hard reject at or above - let projectedCPUBytes = fileSizeBytes * 20 - - let fileMB = String(format: "%.1f", Double(fileSizeBytes) / 1_048_576) - let projGB = String(format: "%.1f", Double(projectedCPUBytes) / 1_073_741_824) - let ramGB = String(format: "%.1f", Double(physicalMemory) / 1_073_741_824) - - if projectedCPUBytes >= hardRejectThreshold { - let thrGB = String(format: "%.1f", Double(hardRejectThreshold) / 1_073_741_824) - handleError(.assetAdmissionRejected, "Stage 1: \(fileMB) MB file, projected ~\(projGB) GB CPU (threshold \(thrGB) GB)", filename) - loadFallbackMesh(entityId: entityId, filename: filename) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return - } else if projectedCPUBytes > softZoneThreshold { - let softGB = String(format: "%.1f", Double(softZoneThreshold) / 1_073_741_824) - Logger.logWarning(message: "[AdmissionGate] Stage 1 SOFT ZONE '\(filename)' — File: \(fileMB) MB | Expansion: 20× | Projected CPU: ~\(projGB) GB | Soft threshold: \(softGB) GB (50% of \(ramGB) GB RAM). Parse will proceed; Stage 2 is the authoritative gate. Typical for texture-heavy assets whose compressed texture bytes do not expand at parse time.") - // Fall through — parse proceeds. Stage 2 is the accurate authority. - } - // else: safe zone (projectedCPU ≤ softZoneThreshold) — allow, no log. - } - - guard let assetData = await Mesh.parseAssetAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - coordinateConversion: coordinateConversion - ) else { - handleError(.assetDataMissing, filename) - loadFallbackMesh(entityId: entityId, filename: filename) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return - } - - // ── Stage 2: Post-parse accurate admission gate ─────────────────────────── - // AssetProfiler measures actual geometry + texture byte estimates from the - // parsed MDLMesh objects. This is the accurate gate; Stage 1 (pre-parse) is - // only a coarse early filter. - // - // IMPORTANT: by the time this check runs, parseAssetAsync() has already - // allocated CPU heap for all MDLMesh buffers. This gate cannot prevent the - // parse-time RAM spike. What it prevents is all downstream work: - // - stub registration (no ECS entities created), - // - MDLAsset retention in rootAssetRefs (no CPU RAM kept permanently), - // - CPU registry storage in ProgressiveAssetLoader. - // When the gate fires, assetData goes out of scope and ARC releases the - // parsed MDLMesh buffers, recovering the RAM that the parse consumed. - // - // The profile is computed regardless of streamingPolicy so all three policy - // modes (.auto, .outOfCore, .immediate) are subject to the same gate. - let assetProfile = AssetProfiler.profile(url: url, assetData: assetData, fileSizeBytes: fileSizeBytes) - let postParsePhysicalMemory = Int(ProcessInfo.processInfo.physicalMemory) - let postParseSafetyThreshold = Int(Double(postParsePhysicalMemory) * 0.75) - if assetProfile.estimatedGeometryBytes > postParseSafetyThreshold { - let geoGB = String(format: "%.1f", Double(assetProfile.estimatedGeometryBytes) / 1_073_741_824) - let thrGB = String(format: "%.1f", Double(postParseSafetyThreshold) / 1_073_741_824) - let ramGB = String(format: "%.1f", Double(postParsePhysicalMemory) / 1_073_741_824) - let fileMBStr = String(format: "%.1f", Double(fileSizeBytes) / 1_048_576) - handleError(.assetAdmissionRejected, "Stage 2: \(fileMBStr) MB file, profiled geometry ~\(geoGB) GB (threshold \(thrGB) GB of \(ramGB) GB RAM)", filename) - // Load fallback so the entity is visually stable — the scene shows a - // placeholder cube rather than an invisible, mesh-less entity. - loadFallbackMesh(entityId: entityId, filename: filename) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return - } - - // Resolve the effective loading policy from the caller's streamingPolicy. - // - // For .auto, AssetProfiler classifies the already-computed assetProfile - // against the live platform memory budget to select independent geometry - // and texture residency policies. - // - // For .outOfCore / .immediate, the caller's intent is mapped directly to the - // policy types for a clean internal representation. - let loadingPolicy: AssetLoadingPolicy - let outOfCoreReason: String? - switch streamingPolicy { - case .outOfCore: - loadingPolicy = .geometryStreaming - outOfCoreReason = "explicit .outOfCore policy" - case .immediate: - loadingPolicy = .fullLoad - outOfCoreReason = nil - case .auto: - let budget = MemoryBudgetManager.shared.meshBudget - loadingPolicy = AssetProfiler.classifyPolicy(profile: assetProfile, budget: budget) - - let fileMB = String(format: "%.1f", Double(fileSizeBytes) / 1_048_576) - let geoMB = String(format: "%.1f", Double(assetProfile.estimatedGeometryBytes) / 1_048_576) - let texMB = String(format: "%.1f", Double(assetProfile.estimatedTextureBytes) / 1_048_576) - let budgetMB = String(format: "%.0f", Double(budget) / 1_048_576) - Logger.log(message: "[AssetProfiler] '\(filename)' (\(fileMB) MB) → \(assetProfile.assetCharacter.rawValue) | geo ~\(geoMB) MB, tex ~\(texMB) MB | budget: \(budgetMB) MB | meshes: \(assetProfile.meshCount)") - Logger.log(message: "[AssetProfiler] Policy → geometry: \(loadingPolicy.geometryPolicy.rawValue), texture: \(loadingPolicy.texturePolicy.rawValue) (source: \(loadingPolicy.source.rawValue))") - - if loadingPolicy.geometryPolicy == .streaming { - outOfCoreReason = "\(assetProfile.assetCharacter.rawValue) asset, geo ~\(geoMB) MB on \(budgetMB) MB budget" - } else { - outOfCoreReason = nil - } - } - - // Detect LOD groups before choosing the loading path. - let topLevelNames = assetData.topLevelObjects.map { - ($0 as? MDLMesh)?.parent?.name ?? $0.name - } - let lodNameDetection = detectImportedLODGroups(fromSourceNames: topLevelNames) - let hasLODGroups = !lodNameDetection.groups.isEmpty - let useOutOfCore = loadingPolicy.geometryPolicy == .streaming - - if useOutOfCore, hasLODGroups { - // LOD + OUT-OF-CORE PATH ──────────────────────────────────────────────── - // Each LOD group becomes ONE entity with a LODComponent whose levels are - // stub LODLevels (empty mesh, .notResident). CPU-side MDLObject data for - // each level is stored in ProgressiveAssetLoader.cpuLODRegistry so - // GeometryStreamingSystem can upload only the active LOD level from RAM - // when the entity enters streaming range — no disk re-read required. - Logger.log( - message: "[OutOfCore] '\(filename)': LOD asset with \(lodNameDetection.groups.count) group(s) — LOD+OOC stub registration (\(assetData.totalObjectCount) objects)", - category: LogCategory.oocStatus.rawValue - ) - - // Build name→MDLObject map using the same naming formula as topLevelNames. - var nameToObject: [String: MDLObject] = [:] - for obj in assetData.topLevelObjects { - let name = (obj as? MDLMesh)?.parent?.name ?? obj.name - nameToObject[name] = obj - } - - let isMultiGroup = lodNameDetection.groups.count > 1 - - // Register AssetInstanceComponent on root for multi-group assets. - if isMultiGroup { - withWorldMutationGate { - registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) - if let inst = scene.get(component: AssetInstanceComponent.self, for: entityId) { - inst.assetURL = url - inst.assetName = filename - inst.importMode = "preserveHierarchy" - } - } - } - - var lodGroupEntityIds: [EntityID] = [] - lodGroupEntityIds.reserveCapacity(lodNameDetection.groups.count) - var cpuLODEntries: [(groupEntityId: EntityID, lodIndex: Int, entry: ProgressiveAssetLoader.CPUMeshEntry)] = [] - cpuLODEntries.reserveCapacity(assetData.totalObjectCount) - - let configuredDistances = LODConfig.shared.lodDistances - - withWorldMutationGate { - for (groupIdx, group) in lodNameDetection.groups.enumerated() { - // Single group: the root entity IS the LOD entity. - // Multi-group: create a child entity per group. - let groupEntityId: EntityID - if isMultiGroup { - groupEntityId = createEntity() - if hasComponent(entityId: groupEntityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: groupEntityId) - } - if hasComponent(entityId: groupEntityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: groupEntityId) - } - setEntityName(entityId: groupEntityId, name: group.baseName) - setParent(childId: groupEntityId, parentId: entityId) - let nodePath = generateStableNodePath(assetName: group.baseName, index: groupIdx) - registerComponent(entityId: groupEntityId, componentType: DerivedAssetNodeComponent.self) - if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: groupEntityId) { - derived.assetRootEntityId = entityId - derived.nodePath = nodePath - } - } else { - groupEntityId = entityId - if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: entityId) - } - if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: entityId) - } - } - - // Seed transform and bounding box from the LOD0 MDLObject. - if let lod0Level = group.levels.first(where: { $0.lodIndex == 0 }), - let lod0Object = nameToObject[lod0Level.sourceName] - { - let worldTransform = composedWorldTransform(for: lod0Object) - applyWorldTransform(worldTransform, to: groupEntityId) - if let mdlMesh = lod0Object as? MDLMesh, - let local = scene.get(component: LocalTransformComponent.self, for: groupEntityId) - { - local.boundingBox = (min: mdlMesh.boundingBox.minBounds, max: mdlMesh.boundingBox.maxBounds) - } - } - - // Build stub LODLevels: empty mesh + .notResident for every level. - let maxLODIndex = group.levels.map(\.lodIndex).max() ?? 0 - var stubLODLevels: [LODLevel] = (0 ... maxLODIndex).map { lodIdx in - LODLevel( - mesh: [], - maxDistance: defaultLODMaxDistance(for: lodIdx, configuredDistances: configuredDistances), - url: url, - assetName: nil - ) - } - for level in group.levels { - stubLODLevels[level.lodIndex] = LODLevel( - mesh: [], - maxDistance: defaultLODMaxDistance(for: level.lodIndex, configuredDistances: configuredDistances), - url: url, - assetName: level.sourceName - ) - } - - // Configure LODComponent with stubs (nothing resident yet). - configureLODComponent(entityId: groupEntityId, lodLevels: stubLODLevels, activeLODIndex: 0) - - // StreamingComponent (.unloaded) so GeometryStreamingSystem picks this up. - registerComponent(entityId: groupEntityId, componentType: StreamingComponent.self) - if let sc = scene.get(component: StreamingComponent.self, for: groupEntityId) { - sc.assetFilename = filename - sc.assetExtension = withExtension - sc.assetName = group.baseName - sc.state = .unloaded - // Placeholder radii — enableStreaming() sets the real values. - sc.streamingRadius = Float.greatestFiniteMagnitude - sc.unloadRadius = Float.greatestFiniteMagnitude - } - - OctreeSystem.shared.registerEntity(groupEntityId) - lodGroupEntityIds.append(groupEntityId) - - // Collect CPU entries (stored outside the gate below). - for level in group.levels { - guard let obj = nameToObject[level.sourceName] else { continue } - let estimatedGPUBytes: Int = { - guard let mdlMesh = obj as? MDLMesh else { return 0 } - let stride = Int((mdlMesh.vertexDescriptor.layouts.firstObject as? MDLVertexBufferLayout)?.stride ?? 48) - return mdlMesh.vertexCount * stride + mdlMesh.vertexCount * 3 * 4 - }() - let entry = ProgressiveAssetLoader.CPUMeshEntry( - object: obj, - vertexDescriptor: vertexDescriptor.model, - textureLoader: assetData.textureLoader, - device: renderInfo.device, - url: url, - filename: filename, - withExtension: withExtension, - uniqueAssetName: level.sourceName, - estimatedGPUBytes: estimatedGPUBytes, - residencyPolicy: loadingPolicy - ) - cpuLODEntries.append((groupEntityId, level.lodIndex, entry)) - } - } - } - - // Store CPU LOD entries outside the gate (lock-based, no ECS mutation). - for (groupEntityId, lodIdx, entry) in cpuLODEntries { - ProgressiveAssetLoader.shared.storeCPULODMesh(entry, for: groupEntityId, lodIndex: lodIdx) - } - - // Keep MDLAsset alive so MDLMeshBufferDataAllocator is not prematurely released. - ProgressiveAssetLoader.shared.storeAsset(assetData.asset, for: entityId) - ProgressiveAssetLoader.shared.registerChildren(lodGroupEntityIds, for: entityId) - ProgressiveAssetLoader.shared.storeRootRehydrationContext(url: url, policy: loadingPolicy, for: entityId) - - Logger.log( - message: "[OutOfCore] '\(filename)': \(lodGroupEntityIds.count) LOD group entities registered — GeometryStreamingSystem will upload active LOD on demand", - category: LogCategory.oocStatus.rawValue - ) - - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(true) - return - } - - if useOutOfCore { - // OUT-OF-CORE PATH ────────────────────────────────────────────────────── - // Register ALL leaf meshes immediately as .unloaded stub entities (ECS-only, - // no GPU allocation). Each stub's MDLMesh data is stored in the CPU registry - // so GeometryStreamingSystem can upload it from RAM when the entity enters - // streaming range — no disk re-read required. - // - // This replaces the old ProgressiveLoadJob / tick() approach: - // Old: upload nearest N → skip rest → skipped entities permanently absent - // New: all entities present from the start, streaming drives GPU residency - Logger.log( - message: "[OutOfCore] '\(filename)': \(outOfCoreReason ?? "policy") → out-of-core stub registration (\(assetData.totalObjectCount) stubs)", - category: LogCategory.oocStatus.rawValue - ) - - // Register AssetInstanceComponent on the root entity so scene-graph - // serialisation can identify this as a multi-mesh asset instance. - withWorldMutationGate { - let assetInstanceComp = AssetInstanceComponent( - assetURL: url, - assetName: filename, - importMode: "preserveHierarchy", - rootPrimPath: nil - ) - registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) - if let instanceComp = scene.get(component: AssetInstanceComponent.self, for: entityId) { - instanceComp.assetURL = assetInstanceComp.assetURL - instanceComp.assetName = assetInstanceComp.assetName - instanceComp.importMode = assetInstanceComp.importMode - instanceComp.rootPrimPath = assetInstanceComp.rootPrimPath - } - } - - // Register ALL stub entities inside a single withWorldMutationGate. - // Batching N stubs into one gate acquisition avoids N × acquire/release - // overhead on assets with hundreds of mesh leaves (e.g. 500 buildings). - var childEntityIds: [EntityID] = [] - childEntityIds.reserveCapacity(assetData.totalObjectCount) - var cpuEntries: [(EntityID, ProgressiveAssetLoader.CPUMeshEntry)] = [] - cpuEntries.reserveCapacity(assetData.totalObjectCount) - - withWorldMutationGate { - for (i, obj) in assetData.topLevelObjects.enumerated() { - let baseName = (obj as? MDLMesh)?.parent?.name ?? obj.name - let uniqueAssetName = "\(baseName)#\(i)" - - let childId = registerProgressiveStubEntity( - mdlObject: obj, - index: i, - uniqueAssetName: uniqueAssetName, - rootEntityId: entityId, - url: url, - filename: filename, - withExtension: withExtension - ) - - // Estimate GPU bytes from MDLMesh vertex/index counts. - // Used by GeometryStreamingSystem for pre-emptive budget reservation - // before starting a CPU→Metal upload, so the budget gate fires before - // a load rather than reacting after allocation. - let estimatedGPUBytes: Int = { - guard let mdlMesh = obj as? MDLMesh else { return 0 } - let stride = Int((mdlMesh.vertexDescriptor.layouts.firstObject as? MDLVertexBufferLayout)?.stride ?? 48) - let vertexBytes = mdlMesh.vertexCount * stride - // Approximate: ~3 indices per vertex (conservative, no sharing assumed) - let indexBytes = mdlMesh.vertexCount * 3 * 4 - return vertexBytes + indexBytes - }() - - let entry = ProgressiveAssetLoader.CPUMeshEntry( - object: obj, - vertexDescriptor: vertexDescriptor.model, - textureLoader: assetData.textureLoader, - device: renderInfo.device, - url: url, - filename: filename, - withExtension: withExtension, - uniqueAssetName: uniqueAssetName, - estimatedGPUBytes: estimatedGPUBytes, - residencyPolicy: loadingPolicy - ) - cpuEntries.append((childId, entry)) - childEntityIds.append(childId) - } - } - - // Store CPU entries outside the gate (lock-based, no ECS mutation). - for (childId, entry) in cpuEntries { - ProgressiveAssetLoader.shared.storeCPUMesh(entry, for: childId) - } - - // Keep MDLAsset alive so the MDLMeshBufferDataAllocator backing all - // child CPU buffers is not released prematurely. - ProgressiveAssetLoader.shared.storeAsset(assetData.asset, for: entityId) - ProgressiveAssetLoader.shared.registerChildren(childEntityIds, for: entityId) - - // Store URL + policy so GeometryStreamingSystem can re-parse from disk if - // releaseWarmAsset() transitions this asset to CPU-cold in a future frame. - ProgressiveAssetLoader.shared.storeRootRehydrationContext( - url: url, - policy: loadingPolicy, - for: entityId - ) - - Logger.log( - message: "[OutOfCore] '\(filename)': \(assetData.totalObjectCount) stubs registered — GeometryStreamingSystem will upload on demand", - category: LogCategory.oocStatus.rawValue - ) - - // Release the loading gate immediately — no GPU work happens here. - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(true) - return - } - - // SMALL-FILE FAST PATH (CPU-parsed) ──────────────────────────────────────── - // File is below the size threshold: create all mesh groups from the - // CPU-parsed data right now, then continue with the normal registration code below. - // Must use makeMeshesFromCPUBuffers (not makeMeshes) because parseAssetAsync - // uses MDLMeshBufferDataAllocator — CPU-heap buffers that MTKMesh(mesh:device:) - // cannot accept directly (MTKModelErrorNoMTLBuffer). makeMeshesFromCPUBuffers - // copies each buffer to a fresh MTKMeshBufferAllocator-backed buffer first. - // - // parseAssetAsync intentionally skips loadTextures() to defer the decompression - // cost. The OOC path calls ensureTexturesLoaded() in uploadFromCPUEntry before - // makeMeshesFromCPUBuffers. This fast path bypasses that route, so we must call - // loadTextures() here to ensure USDZ-embedded textures are decoded — otherwise - // MTKTextureLoader cannot read the pixel data and all textures silently fail. - // - // loadTextures() is a blocking C/ObjC call that can hang indefinitely when - // ModelIO encounters an unsupported image format inside the USDZ archive. - // Running it on a DispatchQueue (not the Swift cooperative pool) isolates the - // hang from other async work. A 15-second deadline resumes the continuation - // with false so the load proceeds without textures rather than freezing - // the render loop via AssetLoadingGate. ResumeOnce guarantees the - // continuation fires exactly once regardless of which side wins the race. - Logger.log(message: "[Streaming] '\(filename)': loadTextures() start") - let textureLoadOK = await withCheckedContinuation { (cont: CheckedContinuation) in - let once = ResumeOnce() - let assetRef = SendableMDLAssetBox(asset: assetData.asset) - let nameForLog = filename - DispatchQueue.global(qos: .userInitiated).async { - assetRef.asset.loadTextures() - once.callOnce { cont.resume(returning: true) } - } - DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) { - once.callOnce { - Logger.logWarning(message: "[Streaming] '\(nameForLog)': loadTextures() timed out after 15s — proceeding without textures") - cont.resume(returning: false) - } - } - } - Logger.log(message: "[Streaming] '\(filename)': loadTextures() \(textureLoadOK ? "complete" : "timed out")") - let smallAssetMeshes: [[Mesh]] = assetData.topLevelObjects.map { obj in - Mesh.makeMeshesFromCPUBuffers( - object: obj, - vertexDescriptor: vertexDescriptor.model, - textureLoader: assetData.textureLoader, - device: renderInfo.device, - flip: true - ) - } - MeshResourceManager.shared.cacheLoadedMeshes(url: url, meshArrays: smallAssetMeshes) +// Synchronously load and set an entity mesh on the calling thread. +// +// This API always uses the **immediate** path: all Metal resources are created in a single +// pass before the function returns. It does not support out-of-core stub registration or +// distance-based streaming — the mesh is permanently GPU-resident after this call. +// +// For large assets or any asset that should benefit from distance-based streaming and +// eviction, use `setEntityMeshAsync(streamingPolicy:)` instead. - // Continue to the validation + registration block below using these meshes. - // ─── SMALL-ASSET CONTINUATION ────────────────────────────────────────────── - let meshes = smallAssetMeshes +/// Controls how `setEntityMeshAsync` manages GPU residency for a loaded asset. +public enum MeshStreamingPolicy: Sendable { + /// Automatic: uses `ProgressiveAssetLoader.fileSizeThresholdBytes` and + /// `outOfCoreObjectCountThreshold` to decide. Large or many-object assets + /// go out-of-core; small assets upload directly. Default. + case auto - if meshes.isEmpty { - handleError(.assetDataMissing, filename) - loadFallbackMesh(entityId: entityId, filename: filename) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return - } + /// Always register leaf meshes as `.unloaded` stub entities. The streaming + /// system uploads each mesh to the GPU when the camera enters `streamingRadius` + /// and evicts it when the camera moves beyond `unloadRadius`. + /// + /// The completion callback fires immediately after stub registration — no GPU + /// work happens at load time. **You must call `enableStreaming(entityId:streamingRadius:unloadRadius:)` + /// inside the completion block** so the streaming system knows the real radii. + case outOfCore - let nonEmptyMeshes = meshes.filter { !$0.isEmpty } + /// Always upload directly to the GPU in a single pass. The mesh is permanently + /// resident and is never evicted by the streaming system. Use for small assets + /// that must be visible without any streaming delay (e.g. character, weapon, HUD). + case immediate +} - // assetName is nil here (progressive path requires nil assetName). +/// Synchronously load a .untold mesh onto an entity. +/// +/// Blocks the calling thread until the asset is fully registered and GPU-resident. +/// Use for small, always-resident assets where you need the mesh available on the +/// next line (e.g. scene initialisation, editor tooling, simple demos). +/// +/// For large assets or anything loaded at runtime, prefer `setEntityMeshAsync` — +/// it loads off the main thread and avoids frame hitches. +public func setEntityMesh( + entityId: EntityID, + filename: String, + withExtension: String, + assetName: String? = nil +) { + guard let url = LoadingSystem.shared.resourceURL( + forResource: filename, + withExtension: withExtension, + subResource: nil + ) else { + handleError(.filenameNotFound, filename) + loadFallbackMesh(entityId: entityId, filename: filename) + return + } - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: 0, totalMeshes: nonEmptyMeshes.count, phase: .registering) + guard RuntimeAssetSource.infer(from: url).kind == .untold else { + Logger.logWarning(message: "[RegistrationSystem] setEntityMesh only supports .untold assets. Use setEntityMeshAsync for other formats.") + loadFallbackMesh(entityId: entityId, filename: filename) + return + } - var loadingEntityIds: [EntityID] = [entityId] + guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { + loadFallbackMesh(entityId: entityId, filename: filename) + return + } - let handledImportedLOD = tryRegisterImportedLODGroup( - entityId: entityId, - url: url, - filename: filename, - withExtension: withExtension, - nonEmptyMeshes: nonEmptyMeshes - ) + let didLoad = registerUntoldRuntimeAsset( + entityId: entityId, + runtimeAsset: runtimeAsset, + url: url, + filename: filename, + withExtension: withExtension, + assetName: assetName + ) - if handledImportedLOD { - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: nonEmptyMeshes.count, totalMeshes: nonEmptyMeshes.count) - } else if nonEmptyMeshes.count == 1 { - let mesh = nonEmptyMeshes[0] - associateMeshesToEntity(entityId: entityId, meshes: mesh) - registerRenderComponent(entityId: entityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) - if let renderComp = scene.get(component: RenderComponent.self, for: entityId) { - renderComp.isVisible = false - } - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: 1, totalMeshes: 1) - } else if nonEmptyMeshes.count > 1 { - let assetInstanceComp = AssetInstanceComponent( - assetURL: url, - assetName: filename, - importMode: "preserveHierarchy", - rootPrimPath: nil - ) - registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) - if let instanceComp = scene.get(component: AssetInstanceComponent.self, for: entityId) { - instanceComp.assetURL = assetInstanceComp.assetURL - instanceComp.assetName = assetInstanceComp.assetName - instanceComp.importMode = assetInstanceComp.importMode - instanceComp.rootPrimPath = assetInstanceComp.rootPrimPath - } - for (index, mesh) in nonEmptyMeshes.enumerated() { - let childEntityId = createEntity() - if hasComponent(entityId: childEntityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: childEntityId) - } - if hasComponent(entityId: childEntityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: childEntityId) - } - if let firstMesh = mesh.first { - applyWorldTransform(firstMesh.worldSpace, to: childEntityId) - } - associateMeshesToEntity(entityId: childEntityId, meshes: mesh) - registerRenderComponent(entityId: childEntityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - let meshAssetName = mesh.first!.assetName - setEntityName(entityId: childEntityId, name: meshAssetName) - setParent(childId: childEntityId, parentId: entityId) - let nodePath = generateStableNodePath(assetName: meshAssetName, index: index) - let derivedComp = DerivedAssetNodeComponent(assetRootEntityId: entityId, nodePath: nodePath) - registerComponent(entityId: childEntityId, componentType: DerivedAssetNodeComponent.self) - if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: childEntityId) { - derived.assetRootEntityId = derivedComp.assetRootEntityId - derived.nodePath = derivedComp.nodePath - } - setEntitySkeleton(entityId: childEntityId, filename: filename, withExtension: withExtension) - if let renderComp = scene.get(component: RenderComponent.self, for: childEntityId) { - renderComp.isVisible = false - } - loadingEntityIds.append(childEntityId) - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: index + 1, totalMeshes: nonEmptyMeshes.count) - } - } + if !didLoad { + loadFallbackMesh(entityId: entityId, filename: filename) + } +} - for id in loadingEntityIds { - if let renderComp = scene.get(component: RenderComponent.self, for: id) { - renderComp.isVisible = true - } - } +/// Asynchronously load and set entity mesh without blocking the main thread +public func setEntityMeshAsync( + entityId: EntityID, + filename: String, + withExtension: String, + assetName: String? = nil, + flip _: Bool = true, + coordinateConversion _: CoordinateSystemConversion = .autoDetect, + streamingPolicy: MeshStreamingPolicy = .immediate, + blockRenderLoop: Bool = true, + completion: ((Bool) -> Void)? = nil +) { + let completionBox = completion.map { BoolCompletionBox(callback: $0) } + Task { + // Mark as loading. Secondary assets (LOD levels, HLODs) pass blockRenderLoop:false — + // the gate is opened and immediately closed so the render loop is never stalled + // waiting for supplementary geometry. All downstream finishLoading calls are + // idempotent no-ops once the entity is already removed from the loading set. + await AssetLoadingState.shared.startLoading(entityId: entityId, filename: filename) + if !blockRenderLoop { await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(true) - return } - // ORIGINAL PATH (assetName specified, or progressive loading disabled) ────────── - // Uses MTKMeshBufferAllocator: all Metal buffers allocated at parse time. - // Kept for named-mesh lookups and fallback when progressive loading is off. - let meshes = await Mesh.loadSceneMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - coordinateConversion: coordinateConversion - ) { current, total in - guard total > 0 else { return } - - Task { - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: current, totalMeshes: total) - } + // Ensure entity has required components while loading gate is active. + if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { + registerTransformComponent(entityId: entityId) } - // Cache meshes for streaming system (so reloads don't require disk I/O) - MeshResourceManager.shared.cacheLoadedMeshes(url: url, meshArrays: meshes) + if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { + registerSceneGraphComponent(entityId: entityId) + } - // Process on main thread - validate meshes first - if meshes.isEmpty { - handleError(.assetDataMissing, filename) + // Get URL + guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { + handleError(.filenameNotFound, filename) loadFallbackMesh(entityId: entityId, filename: filename) await AssetLoadingState.shared.finishLoading(entityId: entityId) completionBox?.call(false) return } - var nonEmptyMeshes = meshes.filter { !$0.isEmpty } + if url.pathExtension == "dae" { + handleError(.fileTypeNotSupported, url.pathExtension) + loadFallbackMesh(entityId: entityId, filename: filename) + await AssetLoadingState.shared.finishLoading(entityId: entityId) + completionBox?.call(false) + return + } - if let assetNameExist = assetName { - if let matchedMesh = nonEmptyMeshes.first(where: { $0.first?.assetName == assetNameExist }) { - nonEmptyMeshes = [matchedMesh] - } else { - handleError(.assetDataMissing, "No mesh with asset name \(assetNameExist)") + if RuntimeAssetSource.infer(from: url).kind == .untold { + guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { loadFallbackMesh(entityId: entityId, filename: filename) await AssetLoadingState.shared.finishLoading(entityId: entityId) completionBox?.call(false) return } - } - - // Register components in batches to avoid blocking - // Update progress to show registration phase - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: 0, totalMeshes: nonEmptyMeshes.count, phase: .registering) - - // Track entities being loaded to hide them during registration - var loadingEntityIds: [EntityID] = [entityId] - - let handledImportedLOD = tryRegisterImportedLODGroup( - entityId: entityId, - url: url, - filename: filename, - withExtension: withExtension, - nonEmptyMeshes: nonEmptyMeshes - ) - - if handledImportedLOD { - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: nonEmptyMeshes.count, totalMeshes: nonEmptyMeshes.count) - } else if nonEmptyMeshes.count == 1 { - let mesh = nonEmptyMeshes[0] - associateMeshesToEntity(entityId: entityId, meshes: mesh) - registerRenderComponent(entityId: entityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) - - // Hide during registration - if let renderComp = scene.get(component: RenderComponent.self, for: entityId) { - renderComp.isVisible = false - } - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: 1, totalMeshes: 1) - } else if nonEmptyMeshes.count > 1 { - // Multi-mesh asset: mark root as AssetInstance, children as DerivedAssetNode - let assetInstanceComp = AssetInstanceComponent( - assetURL: url, - assetName: assetName ?? filename, - importMode: "preserveHierarchy", - rootPrimPath: nil - ) - registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) - if let instanceComp = scene.get(component: AssetInstanceComponent.self, for: entityId) { - instanceComp.assetURL = assetInstanceComp.assetURL - instanceComp.assetName = assetInstanceComp.assetName - instanceComp.importMode = assetInstanceComp.importMode - instanceComp.rootPrimPath = assetInstanceComp.rootPrimPath - } - - // Process mesh groups without artificial delays to maximize import throughput. - for (index, mesh) in nonEmptyMeshes.enumerated() { - let childEntityId = createEntity() - - if hasComponent(entityId: childEntityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: childEntityId) - } - - if hasComponent(entityId: childEntityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: childEntityId) - } - - // Extract full transform (translation, rotation, scale) from mesh world space - // before RenderComponent registration. - if let firstMesh = mesh.first { - applyWorldTransform(firstMesh.worldSpace, to: childEntityId) - } - - associateMeshesToEntity(entityId: childEntityId, meshes: mesh) - registerRenderComponent(entityId: childEntityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - - let meshAssetName = mesh.first!.assetName - setEntityName(entityId: childEntityId, name: meshAssetName) - setParent(childId: childEntityId, parentId: entityId) - // Tag as derived node with stable nodePath - let nodePath = generateStableNodePath(assetName: meshAssetName, index: index) - let derivedComp = DerivedAssetNodeComponent(assetRootEntityId: entityId, nodePath: nodePath) - registerComponent(entityId: childEntityId, componentType: DerivedAssetNodeComponent.self) - if let derived = scene.get(component: DerivedAssetNodeComponent.self, for: childEntityId) { - derived.assetRootEntityId = derivedComp.assetRootEntityId - derived.nodePath = derivedComp.nodePath - } - - setEntitySkeleton(entityId: childEntityId, filename: filename, withExtension: withExtension) - - // Hide during registration - if let renderComp = scene.get(component: RenderComponent.self, for: childEntityId) { - renderComp.isVisible = false + // OCC is only valid for whole-asset loads. Named-node loads always full-load. + let useOCC: Bool + if assetName != nil { + useOCC = false + } else { + switch streamingPolicy { + case .immediate: + useOCC = false + case .outOfCore: + useOCC = true + case .auto: + let renderableNodes = runtimeAsset.nodes.filter { !$0.primitives.isEmpty } + let estimatedGeometryBytes = renderableNodes + .flatMap(\.primitives) + .reduce(0) { $0 + $1.estimatedGPUBytes } + let budget = MemoryBudgetManager.shared.geometryBudget + let budgetFraction: Float = budget > 0 + ? Float(estimatedGeometryBytes) / Float(budget) + : 1.0 + useOCC = renderableNodes.count >= 50 || budgetFraction > 0.30 } + } - // Add child to loading set - loadingEntityIds.append(childEntityId) - - // Update registration progress - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: index + 1, totalMeshes: nonEmptyMeshes.count) + 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 + ) } - } - // Mark all entities as visible now that registration is complete - for id in loadingEntityIds { - if let renderComp = scene.get(component: RenderComponent.self, for: id) { - renderComp.isVisible = true + if !didLoad { + loadFallbackMesh(entityId: entityId, filename: filename) } + + await AssetLoadingState.shared.finishLoading(entityId: entityId) + completionBox?.call(didLoad) + return } + // 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) await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(true) + completionBox?.call(false) } } @@ -2772,24 +1882,6 @@ private func normalizeTileStreamingBands( return (normalizedPrefetch, normalizedHLOD, normalizedLODs) } -// Lightweight second MDLAsset pass that extracts cameras and lights only. -// Uses a bare MDLAsset (no vertex descriptor, no allocator) so no geometry -// buffers are allocated — this is cheap even for large scenes. - -/// Cache to avoid reloading USDZ files multiple times for skeleton checks -private var skeletonCache: [URL: MDLSkeleton?] { - get { - registrationRuntimeState.lock.lock() - defer { registrationRuntimeState.lock.unlock() } - return registrationRuntimeState.skeletonCache - } - set { - registrationRuntimeState.lock.lock() - registrationRuntimeState.skeletonCache = newValue - registrationRuntimeState.lock.unlock() - } -} - func removeEntityMesh(entityId: EntityID) { var removedAnyResourceOwner = false @@ -2818,89 +1910,6 @@ func removeEntityMesh(entityId: EntityID) { MemoryBudgetManager.shared.unregisterMesh(entityId: entityId) } -public func setEntitySkeleton(entityId: EntityID, filename: String, withExtension: String) { - enforceRegistrationMainActor() - guard let url: URL = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { - handleError(.filenameNotFound, filename) - return - } - - // Check cache first to avoid reloading USDZ - let cachedSkeleton: MDLSkeleton? - if let cached = skeletonCache[url] { - cachedSkeleton = cached - } else { - // Not in cache - load USDZ once and cache result - let bufferAllocator = MTKMeshBufferAllocator(device: renderInfo.device) - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor.model, bufferAllocator: bufferAllocator) - let skeletons = asset.childObjects(of: MDLSkeleton.self) as? [MDLSkeleton] ?? [] - cachedSkeleton = skeletons.first - skeletonCache[url] = cachedSkeleton // Cache for future calls - } - - if cachedSkeleton == nil { - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - return - } - - let skin = Skin() - - for index in renderComponent.mesh.indices { - renderComponent.mesh[index].skin = skin - } - - return - } - - let skeleton = Skeleton(mdlSkeleton: cachedSkeleton!)! - - // register Skeleton Component - registerComponent(entityId: entityId, componentType: SkeletonComponent.self) - - guard let skeletonComponent = scene.get(component: SkeletonComponent.self, for: entityId) else { - handleError(.noSkeletonComponent, entityId) - return - } - - skeletonComponent.skeleton = skeleton - - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - return - } - - for mesh in renderComponent.mesh { - setEntitySkin(entityId: entityId, mdlMesh: mesh.modelMDLMesh) - } -} - -public func setEntitySkin(entityId: EntityID, mdlMesh: MDLMesh) { - guard let skeletonComponent = scene.get(component: SkeletonComponent.self, for: entityId) else { - handleError(.noSkeletonComponent, entityId) - return - } - - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - return - } - - let animationBindComponent = mdlMesh.componentConforming(to: MDLComponent.self) as? MDLAnimationBindComponent - - let skin = Skin(animationBindComponent: animationBindComponent, skeleton: skeletonComponent.skeleton) - - // update the buffer with rest pose - skeletonComponent.skeleton.resetPoseToRest() - - skin?.updateJointMatrices(skeleton: skeletonComponent.skeleton) - - // Assign skin to mesh - for index in renderComponent.mesh.indices where renderComponent.mesh[index].modelMDLMesh == mdlMesh { - renderComponent.mesh[index].skin = skin - } -} - public func setEntityAnimations(entityId: EntityID, filename: String, withExtension: String, name: String) { let targetEntityId = resolveEntityForAnimationBinding(entityId: entityId) ?? entityId guard scene.get(component: SkeletonComponent.self, for: targetEntityId) != nil else { @@ -2941,35 +1950,6 @@ public func setEntityAnimations(entityId: EntityID, filename: String, withExtens } return } - - let bufferAllocator = MTKMeshBufferAllocator(device: renderInfo.device) - - let asset = MDLAsset(url: url, vertexDescriptor: vertexDescriptor.model, bufferAllocator: bufferAllocator) - - let assetAnimations = asset.animations.objects.compactMap { - $0 as? MDLPackedJointAnimation - } - - if assetAnimations.isEmpty { - handleError(.assetHasNoAnimation, filename) - return - } - - withWorldMutationGate { - guard let animationComponent = ensureAnimationComponent(entityId: targetEntityId, errorEntityId: entityId) else { - return - } - - for assetAnimation in assetAnimations { - let animationClip = AnimationClip(animation: assetAnimation, animationName: name) - animationComponent.animationClips[name] = animationClip - } - - appendAnimationSourceURLIfNeeded(url, to: animationComponent) - if animationComponent.currentAnimation == nil { - animationComponent.currentAnimation = animationComponent.animationClips[name] - } - } } func removeEntityAnimations(entityId: EntityID) { @@ -3325,10 +2305,13 @@ public func loadRawMesh( return [] } - let meshes = Mesh.loadMeshWithName(name: name, url: url, vertexDescriptor: vertexDescriptor.model, device: renderInfo.device) - - if !meshes.isEmpty { - return meshes + // Load named node from .untold asset. + if let runtimeAsset = loadUntoldRuntimeAsset(url: url), + let node = runtimeAsset.nodes.first(where: { $0.name == name }), + !node.primitives.isEmpty + { + let meshes = makeMeshes(from: node) + if !meshes.isEmpty { return meshes } } // ---- Fallback path: fabricate a safe default mesh ---- @@ -3641,12 +2624,13 @@ public func addLODLevel( } // Load meshes for this LOD - var meshes = await Mesh.loadMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + guard let runtimeAsset = try? NativeFormatLoader().loadAssetSync(from: url) else { + completionBox?.call(false) + return + } + var meshes: [Mesh] = runtimeAsset.nodes + .filter { !$0.primitives.isEmpty } + .flatMap { makeMeshes(from: $0) } // Assign empty skin to all meshes (required by shaders) let skin = Skin() @@ -3837,12 +2821,13 @@ public func replaceLODLevel( } // Load new meshes - var meshes = await Mesh.loadMeshesAsync( - url: newURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + guard let runtimeAsset2 = try? NativeFormatLoader().loadAssetSync(from: newURL) else { + completionBox?.call(false) + return + } + var meshes: [Mesh] = runtimeAsset2.nodes + .filter { !$0.primitives.isEmpty } + .flatMap { makeMeshes(from: $0) } // Assign empty skin to all meshes let skin = Skin() diff --git a/Sources/UntoldEngine/Utils/Globals.swift b/Sources/UntoldEngine/Utils/Globals.swift index 395f3736..d2457c65 100644 --- a/Sources/UntoldEngine/Utils/Globals.swift +++ b/Sources/UntoldEngine/Utils/Globals.swift @@ -583,7 +583,7 @@ private final class RuntimeGlobalsStore: @unchecked Sendable { private var gameModeValue: Bool = true private var applyIBLValue: Bool = false private var renderEnvironmentValue: Bool = false - private var ambientIntensityValue: Float = 1.0 + private var ambientIntensityValue: Float = 0.4 private var hdrURLValue: String = "teatro_massimo_2k.hdr" private var resourceURLValue: URL? private var assetBasePathValue: URL? diff --git a/Tests/UntoldEngineRenderTests/AnimationTest.swift b/Tests/UntoldEngineRenderTests/AnimationTest.swift index cf2e6881..078d9647 100644 --- a/Tests/UntoldEngineRenderTests/AnimationTest.swift +++ b/Tests/UntoldEngineRenderTests/AnimationTest.swift @@ -124,19 +124,25 @@ final class AnimationTests: BaseRenderSetup { override func initializeAssets() { cameraLookAt(entityId: findGameCamera(), eye: simd_float3(0.0, 3.0, 7.0), target: simd_float3(0.0, 0.0, 0.0), up: simd_float3(0.0, 1.0, 0.0)) ambientIntensity = 0.4 - let sunEntity: EntityID = createEntity() createDirLight(entityId: sunEntity) - - // Player (animated, named for lookup). Use synchronous load so the entity - // is fully registered (RenderComponent + SkeletonComponent on its child) - // before setUp calls setVisibleEntities() and tests access findEntity("player"). + // Player entity is created here; actual async load happens in setUp override below. let player = createEntity() - setEntityMesh(entityId: player, filename: "redplayer", withExtension: "untold") - // Name must be set after setEntityMesh: the loader overwrites the root entity's - // name with the asset's root node name during registration. setEntityName(entityId: player, name: "player") + } + + override func setUp() async throws { + try await super.setUp() + // Load the actual redplayer.untold asset so SkeletonComponent and AnimationComponent + // are registered. This must run in an async context so we can await completion. + guard let player = findEntity(name: "player") else { return } + let exp = XCTestExpectation(description: "redplayer loaded") + setEntityMeshAsync(entityId: player, filename: "redplayer", withExtension: "untold") { _ in + exp.fulfill() + } + await fulfillment(of: [exp], timeout: 10) setEntityAnimations(entityId: player, filename: "running", withExtension: "untold", name: "running") changeAnimation(entityId: player, name: "running") + setVisibleEntities() } } diff --git a/Tests/UntoldEngineRenderTests/AsyncLoadingSystemTest.swift b/Tests/UntoldEngineRenderTests/AsyncLoadingSystemTest.swift deleted file mode 100644 index 640a170e..00000000 --- a/Tests/UntoldEngineRenderTests/AsyncLoadingSystemTest.swift +++ /dev/null @@ -1,293 +0,0 @@ -// -// AsyncLoadingSystemTest.swift -// UntoldEngine -// -// Copyright (C) Untold Engine Studios -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/. - -import CShaderTypes -import Foundation -@preconcurrency @testable import UntoldEngine -import XCTest - -extension LoadingProgress: @unchecked Sendable {} -extension LoadingPhase: @unchecked Sendable {} - -final class AsyncLoadingSystemTest: BaseRenderSetup { - override func setUp() async throws { - try await super.setUp() - } - - override func tearDown() async throws { - try await super.tearDown() - - destroyAllEntities() - } - - override func initializeAssets() {} - - // MARK: - AssetLoadingState Tests - - func test_assetLoadingState_startsWithNoActivity() async { - // When/Then: Should report no loading - let isLoading = await AssetLoadingState.shared.isLoadingAny() - let count = await AssetLoadingState.shared.loadingCount() - - // Note: Might have loading from other tests, just verify API works - XCTAssertEqual(isLoading, count > 0, "Loading state should be consistent") - } - - func test_assetLoadingState_tracksLoadingPhase() async { - // Given: Start loading for an entity - let entityId: EntityID = 42 - await AssetLoadingState.shared.startLoading(entityId: entityId, filename: "test.usdz", totalMeshes: 10) - - // When: Update progress in loading phase - await AssetLoadingState.shared.updateProgress( - entityId: entityId, - currentMesh: 5, - totalMeshes: 10, - phase: .loading - ) - - // Then: Should report loading - let isLoading = await AssetLoadingState.shared.isLoadingAny() - XCTAssertTrue(isLoading, "Should be loading") - - let progress = await AssetLoadingState.shared.getProgress(for: entityId) - XCTAssertNotNil(progress, "Progress should exist") - XCTAssertEqual(progress?.currentMesh, 5) - XCTAssertEqual(progress?.totalMeshes, 10) - XCTAssertEqual(progress?.phase, .loading) - - let summary = await AssetLoadingState.shared.loadingSummary() - XCTAssertFalse(summary.isEmpty, "Summary should exist") - XCTAssertTrue(summary.contains("Loading"), "Summary should mention loading") - - // Clean up - await AssetLoadingState.shared.finishLoading(entityId: entityId) - } - - func test_assetLoadingState_tracksRegisteringPhase() async { - // Given: Start loading and move to registering phase - let entityId: EntityID = 100 - await AssetLoadingState.shared.startLoading(entityId: entityId, filename: "test.usdz", totalMeshes: 100) - - // When: Update to registering phase - await AssetLoadingState.shared.updateProgress( - entityId: entityId, - currentMesh: 50, - totalMeshes: 100, - phase: .registering - ) - - // Then: Should show registering phase - let progress = await AssetLoadingState.shared.getProgress(for: entityId) - XCTAssertEqual(progress?.phase, .registering) - - let summary = await AssetLoadingState.shared.loadingSummary() - XCTAssertTrue(summary.contains("Registering"), "Summary should mention registering") - - // Clean up - await AssetLoadingState.shared.finishLoading(entityId: entityId) - } - - func test_assetLoadingState_finishLoading_removesEntity() async { - // Given: Loading entity - let entityId: EntityID = 200 - await AssetLoadingState.shared.startLoading(entityId: entityId, filename: "test.usdz", totalMeshes: 10) - - let isLoadingBefore = await AssetLoadingState.shared.isLoading(entityId: entityId) - XCTAssertTrue(isLoadingBefore, "Should be loading before finish") - - // When: Finish loading - await AssetLoadingState.shared.finishLoading(entityId: entityId) - - // Then: Should no longer be loading - let isLoadingAfter = await AssetLoadingState.shared.isLoading(entityId: entityId) - XCTAssertFalse(isLoadingAfter, "Should not be loading after finish") - - let progress = await AssetLoadingState.shared.getProgress(for: entityId) - XCTAssertNil(progress, "Progress should be removed") - } - - func test_assetLoadingState_tracksMultipleEntities() async { - // Given: Multiple entities loading - await AssetLoadingState.shared.startLoading(entityId: 1, filename: "model1.usdz", totalMeshes: 100) - await AssetLoadingState.shared.startLoading(entityId: 2, filename: "model2.usdz", totalMeshes: 100) - await AssetLoadingState.shared.startLoading(entityId: 3, filename: "model3.usdz", totalMeshes: 100) - - // When: Update progress - await AssetLoadingState.shared.updateProgress(entityId: 1, currentMesh: 10, totalMeshes: 100, phase: .loading) - await AssetLoadingState.shared.updateProgress(entityId: 2, currentMesh: 50, totalMeshes: 100, phase: .registering) - await AssetLoadingState.shared.updateProgress(entityId: 3, currentMesh: 75, totalMeshes: 100, phase: .registering) - - // Then: Should track all entities - let progress1 = await AssetLoadingState.shared.getProgress(for: 1) - let progress2 = await AssetLoadingState.shared.getProgress(for: 2) - let progress3 = await AssetLoadingState.shared.getProgress(for: 3) - - XCTAssertEqual(progress1?.currentMesh, 10) - XCTAssertEqual(progress2?.currentMesh, 50) - XCTAssertEqual(progress3?.currentMesh, 75) - - let isLoading = await AssetLoadingState.shared.isLoadingAny() - XCTAssertTrue(isLoading, "Should be loading with multiple entities") - - // Clean up - await AssetLoadingState.shared.finishLoading(entityId: 1) - await AssetLoadingState.shared.finishLoading(entityId: 2) - await AssetLoadingState.shared.finishLoading(entityId: 3) - } - - // MARK: - Async Mesh Loading Tests - - func test_loadMeshesAsync_loadsSimpleModel() async throws { - // Given: A simple USDZ model - guard let url = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - throw XCTSkip("Could not find ball.usdz - skipping test") - } - - // When: Load meshes asynchronously - final class ProgressTracker: @unchecked Sendable { - var updates: [(Int, Int)] = [] - } - let tracker = ProgressTracker() - let meshes = await Mesh.loadMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true, - coordinateConversion: .autoDetect, - progressHandler: { current, total in - tracker.updates.append((current, total)) - } - ) - - // Then: Should load successfully - XCTAssertFalse(meshes.isEmpty, "Should load at least one mesh") - XCTAssertFalse(tracker.updates.isEmpty, "Should receive progress updates") - - // Verify final progress matches mesh count - if let lastUpdate = tracker.updates.last { - XCTAssertEqual(lastUpdate.0, lastUpdate.1, "Final progress should show completion") - } - } - - func test_loadMeshesAsync_providesAccurateProgress() async throws { - // Given: A model with known mesh count - guard let url = getResourceURL(resourceName: "stadium", ext: "usdz", subName: nil) else { - throw XCTSkip("Could not find stadium.usdz - skipping test") - } - - // When: Load with progress tracking - final class ProgressTracker: @unchecked Sendable { - var updates: [(Int, Int)] = [] - } - let tracker = ProgressTracker() - _ = await Mesh.loadMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true, - coordinateConversion: .autoDetect, - progressHandler: { current, total in - tracker.updates.append((current, total)) - } - ) - - // Then: Progress should be monotonically increasing - for i in 0 ..< tracker.updates.count - 1 { - let (current, total) = tracker.updates[i] - let (nextCurrent, nextTotal) = tracker.updates[i + 1] - - XCTAssertEqual(total, nextTotal, "Total should remain constant") - XCTAssertLessThanOrEqual(current, nextCurrent, "Current should increase") - XCTAssertLessThanOrEqual(current, total, "Current should not exceed total") - } - } - - func test_loadSceneMeshesAsync_loadsMultipleMeshes() async throws { - // Given: A model file - guard let url = getResourceURL(resourceName: "stadium", ext: "usdz", subName: nil) else { - throw XCTSkip("Could not find stadium.usdz - skipping test") - } - - // When: Load scene meshes asynchronously - let meshGroups = await Mesh.loadSceneMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - coordinateConversion: .autoDetect - ) { _, _ in - // Progress callback - } - - // Then: Should load successfully - XCTAssertFalse(meshGroups.isEmpty, "Should load mesh groups") - } - - func test_loadMeshesAsync_handlesMultipleObjects() async throws { - // Given: A model file with multiple objects - guard let url = getResourceURL(resourceName: "stadium", ext: "usdz", subName: nil) else { - throw XCTSkip("Could not find stadium.usdz - skipping test") - } - - // When: Load meshes - let meshes = await Mesh.loadMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true, - coordinateConversion: .autoDetect - ) - - // Then: Should load multiple meshes - XCTAssertFalse(meshes.isEmpty, "Should load meshes from multi-object file") - } - /* - func testDeserializeSceneTriggersAsyncMeshLoad() { - - // Build a scene with a renderable entity, serialize, then deserialize. - let expectedName = "TestModel" - let expectedExt = "usdz" - let expectedURL = URL(fileURLWithPath: "/tmp/\(expectedName).\(expectedExt)") - - let entityId = createEntity() - setEntityName(entityId: entityId, name: "AsyncEntity") - registerTransformComponent(entityId: entityId) - registerSceneGraphComponent(entityId: entityId) - - let meshes = BasicPrimitives.createCube() - registerRenderComponent(entityId: entityId, meshes: meshes, url: expectedURL, assetName: expectedName) - - var sceneData = serializeScene() - sceneData.environment = nil // Bypass HDR generation - destroyAllEntities() - - let expectation = XCTestExpectation(description: "Async mesh load requested") - let originalResourceURLFn = LoadingSystem.shared.resourceURLFn - LoadingSystem.shared.resourceURLFn = { name, ext, _ in - if name == expectedName, ext == expectedExt { - expectation.fulfill() - } - return nil // Return nil to trigger fallback mesh creation - } - defer { - LoadingSystem.shared.resourceURLFn = originalResourceURLFn - } - - // Deserialize with async mode (default) - deserializeScene(sceneData: sceneData, meshLoadingMode: .asyncDefault) - - // Entity should be created immediately even with async loading - XCTAssertEqual(getAllGameEntities().count, 3, "Entity should be created immediately") - - // Wait for the async mesh load to be requested - wait(for: [expectation], timeout: 2.0) - } - */ -} diff --git a/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift b/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift index f4a40e8c..306f4425 100644 --- a/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift +++ b/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift @@ -52,7 +52,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "ball", - withExtension: "usdz" + withExtension: "untold" ) { success in loadSuccess = success expectation.fulfill() @@ -88,7 +88,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "stadium", - withExtension: "usdz" + withExtension: "untold" ) { success in loadSuccess = success expectation.fulfill() @@ -143,7 +143,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "nonexistent_model", - withExtension: "usdz" + withExtension: "untold" ) { success in loadSuccess = success expectation.fulfill() @@ -227,7 +227,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "ball", - withExtension: "usdz" + withExtension: "untold" ) { _ in loadingCompleteExpectation.fulfill() } @@ -273,9 +273,9 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { } } - setEntityMeshAsync(entityId: entity1, filename: "ball", withExtension: "usdz") { _ in expectation1.fulfill() } - setEntityMeshAsync(entityId: entity2, filename: "ball", withExtension: "usdz") { _ in expectation2.fulfill() } - setEntityMeshAsync(entityId: entity3, filename: "ball", withExtension: "usdz") { _ in expectation3.fulfill() } + setEntityMeshAsync(entityId: entity1, filename: "ball", withExtension: "untold") { _ in expectation1.fulfill() } + setEntityMeshAsync(entityId: entity2, filename: "ball", withExtension: "untold") { _ in expectation2.fulfill() } + setEntityMeshAsync(entityId: entity3, filename: "ball", withExtension: "untold") { _ in expectation3.fulfill() } // Then: All should complete await fulfillment(of: [expectation1, expectation2, expectation3], timeout: 10.0) @@ -319,7 +319,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "stadium", - withExtension: "usdz" + withExtension: "untold" ) { _ in expectation.fulfill() } @@ -356,7 +356,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { // When: Load mesh asynchronously let expectation = XCTestExpectation(description: "Mesh loaded") - setEntityMeshAsync(entityId: entityId, filename: "ball", withExtension: "usdz") { _ in + setEntityMeshAsync(entityId: entityId, filename: "ball", withExtension: "untold") { _ in expectation.fulfill() } @@ -379,7 +379,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { // When: Load mesh asynchronously let expectation = XCTestExpectation(description: "Mesh loaded") - setEntityMeshAsync(entityId: entityId, filename: "ball", withExtension: "usdz") { _ in + setEntityMeshAsync(entityId: entityId, filename: "ball", withExtension: "untold") { _ in expectation.fulfill() } @@ -426,7 +426,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { } let expectation = XCTestExpectation(description: "Loading complete") - setEntityMeshAsync(entityId: entityId, filename: "stadium", withExtension: "usdz") { _ in + setEntityMeshAsync(entityId: entityId, filename: "stadium", withExtension: "untold") { _ in expectation.fulfill() } @@ -560,7 +560,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "ball", - withExtension: "usdz", + withExtension: "untold", coordinateConversion: .forceZUpToYUp ) { success in loadSuccess = success @@ -589,7 +589,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "stadium", - withExtension: "usdz", + withExtension: "untold", assetName: "stadium" // Using the file name as asset name ) { success in loadSuccess = success @@ -615,7 +615,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { setEntityMeshAsync( entityId: entityId, filename: "nonexistent", - withExtension: "usdz" + withExtension: "untold" ) { success in loadSuccess = success expectation.fulfill() @@ -652,7 +652,7 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { let exp = XCTestExpectation(description: "Entity \(i) loaded") expectations.append(exp) - setEntityMeshAsync(entityId: entities[i], filename: "ball", withExtension: "usdz") { _ in + setEntityMeshAsync(entityId: entities[i], filename: "ball", withExtension: "untold") { _ in exp.fulfill() } } diff --git a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift index 657f25aa..8ba1762b 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -460,18 +460,21 @@ class BaseRenderSetup: XCTestCase { cameraLookAt(entityId: camera, eye: simd_float3(0.0, 3.0, 7.0), target: simd_float3(0.0, 0.0, 0.0), up: simd_float3(0.0, 1.0, 0.0)) - // Stadium (static mesh) + // Stadium — cube placeholder (no .usdz anymore) let stadium = createEntity() - setEntityMesh(entityId: stadium, filename: "stadium", withExtension: "usdz") - translateBy(entityId: stadium, position: simd_float3(0.0, 0.0, 0.0)) + let stadiumExp = XCTestExpectation(description: "stadium loaded") + + setEntityMesh(entityId: stadium, filename: "stadium", withExtension: "untold") + + rotateBy(entityId: stadium, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) setEntityName(entityId: stadium, name: "stadium") - // Player (animated, named for lookup) + // Player (animated) — load actual .untold asset so AnimationComponent is registered let player = createEntity() - setEntityMesh(entityId: player, filename: "redplayer", withExtension: "untold") setEntityName(entityId: player, name: "player") + setEntityMesh(entityId: player, filename: "redplayer", withExtension: "untold") - // Ball (named for lookup) + // Ball — sphere placeholder let ball = createEntity() setEntityMesh(entityId: ball, filename: "ball", withExtension: "untold") setEntityName(entityId: ball, name: "ball") diff --git a/Tests/UntoldEngineRenderTests/GPUMemoryTest.swift b/Tests/UntoldEngineRenderTests/GPUMemoryTest.swift index ff53ed0f..f9512b41 100644 --- a/Tests/UntoldEngineRenderTests/GPUMemoryTest.swift +++ b/Tests/UntoldEngineRenderTests/GPUMemoryTest.swift @@ -31,7 +31,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testMeshGPUMemorySize_includesVertexAndIndexBuffers() { // Given: Load a mesh that has vertex and index buffers let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "ball") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), let mesh = renderComponent.mesh.first @@ -70,7 +70,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testMeshGPUMemorySize_noUniformBuffers() { // Uniforms are now written per-draw via setVertexBytes — no per-mesh MTLBuffer is allocated. let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "ball") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), let mesh = renderComponent.mesh.first @@ -89,7 +89,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testMeshGPUMemorySize_includesSkinBuffers() { // Given: Load an animated mesh with skin data let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "redplayer", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "redplayer") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), let mesh = renderComponent.mesh.first @@ -118,7 +118,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testMeshGPUMemorySize_calculatesCorrectTotal() { // Given: Load a mesh let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "ball") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), let mesh = renderComponent.mesh.first @@ -158,7 +158,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testMeshTextureMemorySize_sumsSubmeshTextures() { // Given: Load a mesh with textures let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "ball") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), !renderComponent.mesh.isEmpty @@ -223,7 +223,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testMeshTotalGPUMemorySize_sumsGPUAndTextureMemory() { // Given: Load a mesh with both geometry and textures let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "ball") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), !renderComponent.mesh.isEmpty @@ -286,7 +286,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testSubMeshTextureMemorySize_calculatesAllTextureTypes() { // Given: Load a mesh that has materials let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "ball") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), !renderComponent.mesh.isEmpty @@ -377,7 +377,7 @@ final class GPUMemoryTest: BaseRenderSetup { func testSkinGPUMemorySize_calculatesJointTransformBuffer() { // Given: Load an animated mesh with skin let entityId = createEntity() - setEntityMesh(entityId: entityId, filename: "redplayer", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "redplayer") guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), let mesh = renderComponent.mesh.first, @@ -438,10 +438,10 @@ final class GPUMemoryTest: BaseRenderSetup { func testMultipleMeshesTotalMemory() { // Given: Load multiple entities with meshes let entity1 = createEntity() - setEntityMesh(entityId: entity1, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entity1, meshes: BasicPrimitives.createSphere(), assetName: "ball") let entity2 = createEntity() - setEntityMesh(entityId: entity2, filename: "redplayer", withExtension: "usdz") + setEntityMeshDirect(entityId: entity2, meshes: BasicPrimitives.createSphere(), assetName: "redplayer") guard let rc1 = scene.get(component: RenderComponent.self, for: entity1), let rc2 = scene.get(component: RenderComponent.self, for: entity2), diff --git a/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift b/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift index 44283984..02322f56 100644 --- a/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift +++ b/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift @@ -8,7 +8,6 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -import ModelIO import simd @testable import UntoldEngine import XCTest @@ -155,40 +154,42 @@ final class GeometryStreamingEvictionTests: BaseRenderSetup { return entityId } - // MARK: - Test 1: CPUMeshEntry estimatedGPUBytes round-trip + // MARK: - Test 1: CPURuntimeEntry estimatedGPUBytes round-trip - /// `estimatedGPUBytes` is computed at stub-registration time and stored in - /// `CPUMeshEntry`. Verify the value survives `storeCPUMesh` / `retrieveCPUMesh` - /// and is cleared by `removeCPUMesh`. - func testCPUMeshEntryEstimatedGPUBytesStoredAndRetrieved() { + /// `estimatedGPUBytes` is stored in `CPURuntimeEntry` at stub-registration time. + /// Verify the value survives `storeCPURuntimeEntry` / `retrieveCPURuntimeEntry` + /// and is cleared by `removeCPURuntimeEntry`. + func testCPURuntimeEntryEstimatedGPUBytesStoredAndRetrieved() { let entityId: EntityID = 99001 let expectedBytes = 1_234_567 - let entry = ProgressiveAssetLoader.CPUMeshEntry( - object: MDLObject(), - vertexDescriptor: MDLVertexDescriptor(), - textureLoader: TextureLoader(device: renderInfo.device), - device: renderInfo.device, + let dummyNode = RuntimeAssetNode( + id: 0, + name: "TestNode", + localBounds: RuntimeAABB(min: .zero, max: simd_float3(1, 1, 1)), + worldBounds: RuntimeAABB(min: .zero, max: simd_float3(1, 1, 1)), + primitives: [] + ) + let entry = ProgressiveAssetLoader.CPURuntimeEntry( + node: dummyNode, url: URL(fileURLWithPath: "/dev/null"), - filename: "test", - withExtension: "usdz", uniqueAssetName: "TestMesh#0", estimatedGPUBytes: expectedBytes, residencyPolicy: .fullLoad ) - ProgressiveAssetLoader.shared.storeCPUMesh(entry, for: entityId) + ProgressiveAssetLoader.shared.storeCPURuntimeEntry(entry, for: entityId) - let retrieved = ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId) + let retrieved = ProgressiveAssetLoader.shared.retrieveCPURuntimeEntry(for: entityId) XCTAssertEqual( retrieved?.estimatedGPUBytes, expectedBytes, - "estimatedGPUBytes should survive storeCPUMesh / retrieveCPUMesh round-trip" + "estimatedGPUBytes should survive storeCPURuntimeEntry / retrieveCPURuntimeEntry round-trip" ) - ProgressiveAssetLoader.shared.removeCPUMesh(for: entityId) + ProgressiveAssetLoader.shared.removeCPURuntimeEntry(for: entityId) XCTAssertNil( - ProgressiveAssetLoader.shared.retrieveCPUMesh(for: entityId), - "Entry should be absent after removeCPUMesh" + ProgressiveAssetLoader.shared.retrieveCPURuntimeEntry(for: entityId), + "Entry should be absent after removeCPURuntimeEntry" ) } @@ -333,3 +334,96 @@ final class GeometryStreamingEvictionTests: BaseRenderSetup { "At least one near-band load should have been started") } } + +// MARK: - OCC Transform Correctness Tests + +/// Verifies that OCC stub entities land at their node's world-space position even when +/// the direct parent entity has a non-identity world transform. +/// +/// This is the critical invariant for .untold tile geometry, which is exported in world space. +/// The fix: registerUntoldProgressiveStubEntity computes childLocal = inverse(parentWorld) × nodeWorld +/// so that, after setParent, childWorld = parentWorld × childLocal = nodeWorld. +@MainActor +final class NativeFormatOCCTransformTests: BaseRenderSetup { + override func initializeAssets() {} + + func testOCCStubWorldTransformPreservedUnderNonIdentityParent() { + // Container node placed at world position (5, 0, 0) + let containerEntity = createEntity() + if !hasComponent(entityId: containerEntity, componentType: LocalTransformComponent.self) { + registerTransformComponent(entityId: containerEntity) + } + var containerWorld = matrix_identity_float4x4 + containerWorld.columns.3 = simd_float4(5, 0, 0, 1) + applyWorldTransform(containerWorld, to: containerEntity) + // Force world transform calculation so parent is up-to-date before child is created. + syncWorldTransformAndMarkOctreeDirty(entityId: containerEntity) + + // OCC stub should land at world position (3, 2, 1) — independent of container. + let stubEntity = createEntity() + if !hasComponent(entityId: stubEntity, componentType: LocalTransformComponent.self) { + registerTransformComponent(entityId: stubEntity) + } + var nodeWorldTransform = matrix_identity_float4x4 + nodeWorldTransform.columns.3 = simd_float4(3, 2, 1, 1) + + // Replicate what registerUntoldProgressiveStubEntity now does: + // compute local = inverse(parentWorld) × nodeWorld, then parent. + let parentWorld = scene.get(component: WorldTransformComponent.self, for: containerEntity)?.space + ?? matrix_identity_float4x4 + let localTransform = simd_mul(parentWorld.inverse, nodeWorldTransform) + applyWorldTransform(localTransform, to: stubEntity) + setParent(childId: stubEntity, parentId: containerEntity) + + // After setParent: childWorld = parentWorld × childLocal + // = containerWorld × inverse(containerWorld) × nodeWorldTransform + // = nodeWorldTransform + let actualWorld = scene.get(component: WorldTransformComponent.self, for: stubEntity)?.space + let actualPos = simd_float3( + actualWorld?.columns.3.x ?? Float.nan, + actualWorld?.columns.3.y ?? Float.nan, + actualWorld?.columns.3.z ?? Float.nan + ) + + XCTAssertEqual(actualPos.x, 3.0, accuracy: 0.001, + "OCC stub X must equal node world position, not be double-transformed by parent") + XCTAssertEqual(actualPos.y, 2.0, accuracy: 0.001, + "OCC stub Y must equal node world position") + XCTAssertEqual(actualPos.z, 1.0, accuracy: 0.001, + "OCC stub Z must equal node world position") + } + + func testOCCStubWorldTransformIdentityParentIsUnchanged() { + // Sanity check: identity parent should not alter world position. + let containerEntity = createEntity() + if !hasComponent(entityId: containerEntity, componentType: LocalTransformComponent.self) { + registerTransformComponent(entityId: containerEntity) + } + // Container stays at identity (world origin) + syncWorldTransformAndMarkOctreeDirty(entityId: containerEntity) + + let stubEntity = createEntity() + if !hasComponent(entityId: stubEntity, componentType: LocalTransformComponent.self) { + registerTransformComponent(entityId: stubEntity) + } + var nodeWorldTransform = matrix_identity_float4x4 + nodeWorldTransform.columns.3 = simd_float4(7, -2, 4, 1) + + let parentWorld = scene.get(component: WorldTransformComponent.self, for: containerEntity)?.space + ?? matrix_identity_float4x4 + let localTransform = simd_mul(parentWorld.inverse, nodeWorldTransform) + applyWorldTransform(localTransform, to: stubEntity) + setParent(childId: stubEntity, parentId: containerEntity) + + let actualWorld = scene.get(component: WorldTransformComponent.self, for: stubEntity)?.space + let actualPos = simd_float3( + actualWorld?.columns.3.x ?? Float.nan, + actualWorld?.columns.3.y ?? Float.nan, + actualWorld?.columns.3.z ?? Float.nan + ) + + XCTAssertEqual(actualPos.x, 7.0, accuracy: 0.001, "Identity parent: stub X must equal node world position") + XCTAssertEqual(actualPos.y, -2.0, accuracy: 0.001, "Identity parent: stub Y must equal node world position") + XCTAssertEqual(actualPos.z, 4.0, accuracy: 0.001, "Identity parent: stub Z must equal node world position") + } +} diff --git a/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift b/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift index 4b8547b3..ef811a40 100644 --- a/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift +++ b/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift @@ -55,7 +55,7 @@ final class GeometryStreamingTest: BaseRenderSetup { // Given: StreamingComponent with convenience initializer let component = StreamingComponent( filename: "testModel", - withExtension: "usdz", + withExtension: "untold", streamingRadius: 50.0, unloadRadius: 75.0, priority: 5 @@ -63,7 +63,7 @@ final class GeometryStreamingTest: BaseRenderSetup { // Then: Should have provided values XCTAssertEqual(component.assetFilename, "testModel", "❌ Filename should match") - XCTAssertEqual(component.assetExtension, "usdz", "❌ Extension should match") + XCTAssertEqual(component.assetExtension, "untold", "❌ Extension should match") XCTAssertEqual(component.streamingRadius, 50.0, "❌ Streaming radius should match") XCTAssertEqual(component.unloadRadius, 75.0, "❌ Unload radius should match") XCTAssertEqual(component.priority, 5, "❌ Priority should match") @@ -95,22 +95,12 @@ final class GeometryStreamingTest: BaseRenderSetup { func testEnableStreamingForSingleMeshEntity() { // Given: A single-mesh entity with RenderComponent - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") renderComponent.assetName = "ball" } @@ -133,7 +123,7 @@ final class GeometryStreamingTest: BaseRenderSetup { XCTAssertEqual(streaming?.priority, 10, "❌ Priority should match") XCTAssertEqual(streaming?.state, .loaded, "❌ State should be .loaded (mesh already present)") XCTAssertEqual(streaming?.assetFilename, "ball", "❌ Filename should be extracted from URL") - XCTAssertEqual(streaming?.assetExtension, "usdz", "❌ Extension should be extracted from URL") + XCTAssertEqual(streaming?.assetExtension, "untold", "❌ Extension should be extracted from URL") } func testEnableStreamingFailsWithoutRenderComponent() { @@ -206,7 +196,7 @@ final class GeometryStreamingTest: BaseRenderSetup { // When: Create a streaming entity (deferred loading) let entity = createStreamingEntity( filename: "testModel", - withExtension: "usdz", + withExtension: "untold", streamingRadius: 200.0, unloadRadius: 300.0, priority: 2 @@ -223,7 +213,7 @@ final class GeometryStreamingTest: BaseRenderSetup { let streaming = scene.get(component: StreamingComponent.self, for: entity) XCTAssertNotNil(streaming, "❌ Should have StreamingComponent") XCTAssertEqual(streaming?.assetFilename, "testModel", "❌ Filename should match") - XCTAssertEqual(streaming?.assetExtension, "usdz", "❌ Extension should match") + XCTAssertEqual(streaming?.assetExtension, "untold", "❌ Extension should match") XCTAssertEqual(streaming?.streamingRadius, 200.0, "❌ Streaming radius should match") XCTAssertEqual(streaming?.unloadRadius, 300.0, "❌ Unload radius should match") XCTAssertEqual(streaming?.priority, 2, "❌ Priority should match") @@ -251,24 +241,14 @@ final class GeometryStreamingTest: BaseRenderSetup { func testGetStatsWithStreamingEntities() { // Given: Create streaming entities with different states - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - // Create 2 loaded entities for _ in 0 ..< 2 { let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -299,22 +279,12 @@ final class GeometryStreamingTest: BaseRenderSetup { func testStreamingUpdateUnloadsDistantEntities() { // Given: A loaded streaming entity - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -356,26 +326,16 @@ final class GeometryStreamingTest: BaseRenderSetup { } func testStreamingUpdateRespectsUnloadBudgetPerTick() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - GeometryStreamingSystem.shared.maxUnloadsPerUpdate = 1 GeometryStreamingSystem.shared.enabled = true func makeLoadedEntity(positionX: Float) -> EntityID { let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -413,22 +373,12 @@ final class GeometryStreamingTest: BaseRenderSetup { func testForceUnload() { // Given: A loaded streaming entity - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -456,7 +406,7 @@ final class GeometryStreamingTest: BaseRenderSetup { // Given: Multiple unloaded streaming entities with different priorities let lowPriorityEntity = createStreamingEntity( filename: "model", - withExtension: "usdz", + withExtension: "untold", streamingRadius: 100.0, unloadRadius: 150.0, priority: 1 @@ -464,7 +414,7 @@ final class GeometryStreamingTest: BaseRenderSetup { let highPriorityEntity = createStreamingEntity( filename: "model", - withExtension: "usdz", + withExtension: "untold", streamingRadius: 100.0, unloadRadius: 150.0, priority: 10 @@ -485,22 +435,12 @@ final class GeometryStreamingTest: BaseRenderSetup { func testUnloadRadiusMustBeGreaterThanStreamingRadius() { // Given: An entity with streaming - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -529,7 +469,7 @@ final class GeometryStreamingTest: BaseRenderSetup { // Given: A deferred streaming entity created outside any tile hierarchy. let entity = createStreamingEntity( filename: "ball", - withExtension: "usdz", + withExtension: "untold", streamingRadius: 200.0, unloadRadius: 300.0, priority: 0 @@ -558,23 +498,13 @@ final class GeometryStreamingTest: BaseRenderSetup { func testStreamingIntegrationWithRendering() { // Given: Create streaming entities - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - for i in 0 ..< 3 { let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { diff --git a/Tests/UntoldEngineRenderTests/NativeFormatHierarchyTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatHierarchyTests.swift index 5082ea22..4bd206bb 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatHierarchyTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatHierarchyTests.swift @@ -80,7 +80,7 @@ final class NativeFormatHierarchyRegistrationTests: BaseRenderSetup { override func initializeAssets() {} - func testSetEntityMesh_buildsEntityHierarchyFromUntoldNodes() throws { + func testSetEntityMesh_buildsEntityHierarchyFromUntoldNodes() async throws { let fixture = try makeHierarchicalUntoldFixture() let originalResourceURLFn = LoadingSystem.shared.resourceURLFn LoadingSystem.shared.resourceURLFn = { name, ext, _ in @@ -92,7 +92,9 @@ final class NativeFormatHierarchyRegistrationTests: BaseRenderSetup { let rootEntity = createEntity() setEntityName(entityId: rootEntity, name: "HierarchyRoot") - setEntityMesh(entityId: rootEntity, filename: "hierarchy", withExtension: "untold") + let loadExp_rootEntity = expectation(description: "hierarchy loaded") + setEntityMeshAsync(entityId: rootEntity, filename: "hierarchy", withExtension: "untold") { _ in loadExp_rootEntity.fulfill() } + await fulfillment(of: [loadExp_rootEntity], timeout: 10) // rootEntity is the scene container — keeps its original name, no mesh, no derived tag. XCTAssertTrue(hasComponent(entityId: rootEntity, componentType: AssetInstanceComponent.self)) @@ -138,7 +140,7 @@ final class NativeFormatHierarchyRegistrationTests: BaseRenderSetup { XCTAssertTrue(transformsApproximatelyEqualForTest(childRender.mesh[0].localSpace, matrix_identity_float4x4)) } - func testSetEntityMesh_buildsEntityHierarchyFromRealUntoldFixture() throws { + func testSetEntityMesh_buildsEntityHierarchyFromRealUntoldFixture() async throws { guard let url = Bundle.module.url(forResource: "cubeparentchild", withExtension: "untold") else { XCTFail("Failed to locate cubeparentchild.untold in test resources") return @@ -155,7 +157,9 @@ final class NativeFormatHierarchyRegistrationTests: BaseRenderSetup { let rootEntity = createEntity() setEntityName(entityId: rootEntity, name: "CubeParentChildRoot") - setEntityMesh(entityId: rootEntity, filename: "cubeparentchild", withExtension: "untold") + let loadExp_rootEntity = expectation(description: "cubeparentchild loaded") + setEntityMeshAsync(entityId: rootEntity, filename: "cubeparentchild", withExtension: "untold") { _ in loadExp_rootEntity.fulfill() } + await fulfillment(of: [loadExp_rootEntity], timeout: 10) let allDerivedNodes = collectDescendantEntities(from: rootEntity).filter { hasComponent(entityId: $0, componentType: DerivedAssetNodeComponent.self) diff --git a/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift b/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift index 21e74b6e..b1cdabbf 100644 --- a/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift +++ b/Tests/UntoldEngineRenderTests/NativeFormatRegistrationTests.swift @@ -30,11 +30,13 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { override func initializeAssets() {} - func testSetEntityMesh_loadsUntoldMesh() { + func testSetEntityMesh_loadsUntoldMesh() async { let entityId = createEntity() setEntityName(entityId: entityId, name: "UntoldSyncEntity") - setEntityMesh(entityId: entityId, filename: "redplayer", withExtension: "untold") + let loadExp_entityId = expectation(description: "redplayer loaded") + setEntityMeshAsync(entityId: entityId, filename: "redplayer", withExtension: "untold") { _ in loadExp_entityId.fulfill() } + await fulfillment(of: [loadExp_entityId], timeout: 10) // The root entity always gets transform + scenegraph components. XCTAssertTrue(hasComponent(entityId: entityId, componentType: LocalTransformComponent.self)) @@ -109,7 +111,9 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { let entityId = createEntity() setEntityName(entityId: entityId, name: "NamedNodeEntity") - setEntityMesh(entityId: entityId, filename: "redplayer", withExtension: "untold", assetName: nodeName) + let namedLoadExp = expectation(description: "named node loaded") + setEntityMeshAsync(entityId: entityId, filename: "redplayer", withExtension: "untold", assetName: nodeName) { _ in namedLoadExp.fulfill() } + await fulfillment(of: [namedLoadExp], timeout: 10) // Named-node load registers the mesh directly on entityId — no child entities. XCTAssertTrue( @@ -127,12 +131,14 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { XCTAssertEqual(renderComponent.assetName, nodeName, "RenderComponent assetName must match requested node name") } - func testSetEntityMesh_returnsFalseForUnknownNodeName() { + func testSetEntityMesh_returnsFalseForUnknownNodeName() async { let entityId = createEntity() setEntityName(entityId: entityId, name: "BadNameEntity") // An unknown assetName should fall back to the fallback mesh, not crash. - setEntityMesh(entityId: entityId, filename: "redplayer", withExtension: "untold", assetName: "nonexistent_node_xyz") + let badNameExp = expectation(description: "bad name loaded") + setEntityMeshAsync(entityId: entityId, filename: "redplayer", withExtension: "untold", assetName: "nonexistent_node_xyz") { _ in badNameExp.fulfill() } + await fulfillment(of: [badNameExp], timeout: 10) // The entity should still have components (fallback mesh registers them), // but no RenderComponent with the bad name. @@ -141,7 +147,7 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { } } - func testSetEntityAnimations_resolvesHierarchicalUntoldRootToSkinnedDescendant() throws { + func testSetEntityAnimations_resolvesHierarchicalUntoldRootToSkinnedDescendant() async throws { guard let modelURL = Bundle.module.url(forResource: "redplayer", withExtension: "untold") else { XCTFail("Failed to locate redplayer.untold") return @@ -166,7 +172,9 @@ final class NativeFormatRegistrationTests: BaseRenderSetup { let rootEntity = createEntity() setEntityName(entityId: rootEntity, name: "HierarchicalUntoldRoot") - setEntityMesh(entityId: rootEntity, filename: "redplayer", withExtension: "untold") + let loadExp_rootEntity = expectation(description: "redplayer loaded") + setEntityMeshAsync(entityId: rootEntity, filename: "redplayer", withExtension: "untold") { _ in loadExp_rootEntity.fulfill() } + await fulfillment(of: [loadExp_rootEntity], timeout: 10) let bindingEntity = try XCTUnwrap(resolveEntityForAnimationBinding(entityId: rootEntity)) XCTAssertNotEqual(bindingEntity, rootEntity) diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png index a2af98bf..2a7d25f2 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png index 744e5743..5dca2c9c 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ChromaticAberrationReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png index 479abaa8..342f4e20 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorGradingReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png index 1454a228..e54bdca8 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/ColorTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png index 85e6ea6e..deeb97a0 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/CompositeColorTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png index d5d42e94..4640970a 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/DepthOfFieldReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png index a2ae98bd..1f2a9876 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FXAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png index 9f24b2f1..e42d7aec 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/LightPassColorReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png index a618e776..f7b3b408 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/PositionTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png new file mode 100644 index 00000000..0af7a132 Binary files /dev/null and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png index 49c4948f..4880935d 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SSAOReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png index 9f24b2f1..e42d7aec 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/TransparencyTargetReference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png index 4109f142..ad53d816 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/VignetteReference.png differ diff --git a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift index d65e3490..4788fcbe 100644 --- a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift +++ b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift @@ -404,7 +404,7 @@ final class SceneSerializerTests: BaseRenderSetup { func testRoundTripAnimationComponentViaJSON() { let entityId = createEntity() setEntityName(entityId: entityId, name: "AnimatedEntity") - setEntityMesh(entityId: entityId, filename: "redplayer", withExtension: "usdz") + setEntityMeshDirect(entityId: entityId, meshes: BasicPrimitives.createSphere(), assetName: "redplayer") let animationURL = LoadingSystem.shared.resourceURL(forResource: "running", withExtension: "usdz") guard let animationURL else { @@ -826,6 +826,33 @@ final class SceneSerializerTests: BaseRenderSetup { SSAOParams.shared.intensity = 0.0 } + func testSerializeAntiAliasingSettings() { + // Modify anti-aliasing + antiAliasingMode = .smaa + FXAAParams.shared.subpixelQuality = 0.61 + FXAAParams.shared.edgeThreshold = 0.11 + FXAAParams.shared.edgeThresholdMin = 0.04 + SMAAParams.shared.edgeThreshold = 0.18 + + // Serialize + let sceneData = serializeScene() + + // Verify + XCTAssertNotNil(sceneData.antiAliasing, "Anti-aliasing data should be serialized") + XCTAssertEqual(sceneData.antiAliasing?.mode, .smaa, "Anti-aliasing mode should match") + XCTAssertEqual(sceneData.antiAliasing?.fxaa.subpixelQuality ?? -1.0, 0.61, accuracy: 0.0001, "FXAA subpixel quality should match") + XCTAssertEqual(sceneData.antiAliasing?.fxaa.edgeThreshold ?? -1.0, 0.11, accuracy: 0.0001, "FXAA edge threshold should match") + XCTAssertEqual(sceneData.antiAliasing?.fxaa.edgeThresholdMin ?? -1.0, 0.04, accuracy: 0.0001, "FXAA minimum edge threshold should match") + XCTAssertEqual(sceneData.antiAliasing?.smaa.edgeThreshold ?? -1.0, 0.18, accuracy: 0.0001, "SMAA edge threshold should match") + + // Reset + antiAliasingMode = .fxaa + FXAAParams.shared.subpixelQuality = 0.75 + FXAAParams.shared.edgeThreshold = 0.125 + FXAAParams.shared.edgeThresholdMin = 0.0625 + SMAAParams.shared.edgeThreshold = 0.1 + } + // MARK: - Asset Instance Override Tests func testSerializeSceneStoresDerivedNodeNameOverride() { @@ -933,6 +960,40 @@ final class SceneSerializerTests: BaseRenderSetup { VignetteParams.shared.enabled = false } + func testDeserializeAntiAliasingSettings() { + // Create scene data with anti-aliasing + var sceneData = SceneData() + sceneData.antiAliasing = AntiAliasingData( + mode: .smaa, + fxaa: FXAAData( + subpixelQuality: 0.44, + edgeThreshold: 0.08, + edgeThresholdMin: 0.02 + ), + smaa: SMAAData(edgeThreshold: 0.22) + ) + + // Deserialize + deserializeScene(sceneData: sceneData) + + // Verify anti-aliasing was applied + if case .smaa = antiAliasingMode { + } else { + XCTFail("Anti-aliasing mode should be SMAA") + } + XCTAssertEqual(FXAAParams.shared.subpixelQuality, 0.44, accuracy: 0.0001, "FXAA subpixel quality should be applied") + XCTAssertEqual(FXAAParams.shared.edgeThreshold, 0.08, accuracy: 0.0001, "FXAA edge threshold should be applied") + XCTAssertEqual(FXAAParams.shared.edgeThresholdMin, 0.02, accuracy: 0.0001, "FXAA minimum edge threshold should be applied") + XCTAssertEqual(SMAAParams.shared.edgeThreshold, 0.22, accuracy: 0.0001, "SMAA edge threshold should be applied") + + // Cleanup + antiAliasingMode = .fxaa + FXAAParams.shared.subpixelQuality = 0.75 + FXAAParams.shared.edgeThreshold = 0.125 + FXAAParams.shared.edgeThresholdMin = 0.0625 + SMAAParams.shared.edgeThreshold = 0.1 + } + // MARK: - Parent-Child Hierarchy Tests func testSerializeParentChildHierarchy() { diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index f2d81253..a27017cd 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -168,18 +168,8 @@ final class StaticBatchingTest: BaseRenderSetup { func testGenerateBatchesWithMultipleStaticEntities() { // Given: Load a simple model - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - // Load meshes once so all entities share the same texture object identity - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() // Create multiple entities with same mesh (same material) var entities: [EntityID] = [] @@ -189,7 +179,7 @@ final class StaticBatchingTest: BaseRenderSetup { // Add RenderComponent if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } @@ -223,17 +213,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchGroupBufferCreation() { // Given: Load a model and create batched entities - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() for _ in 0 ..< 3 { let entity = createEntity() @@ -271,12 +251,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testClearBatches() { // Given: Create some batches - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes(url: ballURL, vertexDescriptor: vertexDescriptor.model, device: renderInfo.device, flip: true) + let meshes = BasicPrimitives.createSphere() for _ in 0 ..< 3 { let entity = createEntity() @@ -430,19 +405,10 @@ final class StaticBatchingTest: BaseRenderSetup { // Measure performance of batch generation with many entities measure { // Given: Create 50 static entities - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - return - } - for _ in 0 ..< 50 { let entity = createEntity() - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes @@ -467,17 +433,7 @@ final class StaticBatchingTest: BaseRenderSetup { // This test verifies that batching integrates with the rendering system // Given: Create batched entities - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() for i in 0 ..< 3 { let entity = createEntity() @@ -620,17 +576,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchStatistics() { // Given: Create entities and generate batches - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() let entityCount = 10 for _ in 0 ..< entityCount { @@ -673,17 +619,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchingSystemMovesEntityToNewBatchOnLODChange() { // Given: Create batched entities with LOD components // Note: BatchingSystem requires at least 2 entities with same material+LOD to form a batch - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() // Create 4 entities at LOD 0 var lod0Entities: [EntityID] = [] @@ -692,7 +628,7 @@ final class StaticBatchingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -761,17 +697,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchingSystemRemovesEntityOnMeshEviction() { // Given: Create batched entities - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() var entities: [EntityID] = [] for _ in 0 ..< 4 { @@ -779,7 +705,7 @@ final class StaticBatchingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } @@ -809,7 +735,7 @@ final class StaticBatchingTest: BaseRenderSetup { let evictionEvent = AssetResidencyChangedEvent( entityId: targetEntity, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -831,25 +757,15 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchingSystemAddsEntityWhenMeshBecomesResident() { // Given: Create an entity without mesh initially - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - // Load meshes once so all entities share the same texture object identity - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() // First, create some batched entities so there's a batch to join for _ in 0 ..< 3 { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -860,7 +776,7 @@ final class StaticBatchingTest: BaseRenderSetup { let targetEntity = createEntity() if let renderComponent = scene.assign(to: targetEntity, component: RenderComponent.self) { renderComponent.mesh = [] // Empty initially - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } _ = scene.assign(to: targetEntity, component: LocalTransformComponent.self) @@ -882,7 +798,7 @@ final class StaticBatchingTest: BaseRenderSetup { // Emit residency change event indicating mesh became resident let residencyEvent = AssetResidencyChangedEvent( entityId: targetEntity, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: true ) @@ -906,25 +822,15 @@ final class StaticBatchingTest: BaseRenderSetup { } func testQuiescenceDelaysPromotionToBatchPending() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(2) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() for _ in 0 ..< 3 { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -934,7 +840,7 @@ final class StaticBatchingTest: BaseRenderSetup { let targetEntity = createEntity() if let renderComponent = scene.assign(to: targetEntity, component: RenderComponent.self) { renderComponent.mesh = [] - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } _ = scene.assign(to: targetEntity, component: LocalTransformComponent.self) @@ -951,7 +857,7 @@ final class StaticBatchingTest: BaseRenderSetup { SystemEventBus.shared.queueResidencyChange( AssetResidencyChangedEvent( entityId: targetEntity, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: true ) @@ -980,17 +886,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testTickProcessesPendingRemovalsAndAdditions() { // Given: Create batched entities // Note: tick() processes pending entity changes and rebuilds affected cells incrementally - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() // Create entities at LOD 0 var lod0Entities: [EntityID] = [] @@ -998,7 +894,7 @@ final class StaticBatchingTest: BaseRenderSetup { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { transform.position = simd_float3(Float(i) * 2.0, 0, 0) @@ -1021,7 +917,7 @@ final class StaticBatchingTest: BaseRenderSetup { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { transform.position = simd_float3(Float(i) * 2.0, 10, 0) @@ -1058,7 +954,7 @@ final class StaticBatchingTest: BaseRenderSetup { } let evictionEvent = AssetResidencyChangedEvent( entityId: entityToEvict, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -1098,26 +994,16 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickRebuildsOnlyDirtyCells() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setBatchCellSize(10.0) enableBatching(true) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() func makeEntity(position: simd_float3) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } if let localTransform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -1147,7 +1033,7 @@ final class StaticBatchingTest: BaseRenderSetup { } let evictionEvent = AssetResidencyChangedEvent( entityId: cell0EntityA, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -1171,27 +1057,17 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickProcessesRemainingDirtyCellsAcrossFramesWhenBudgetLimited() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setMaxDirtyCellsPerTick(1) enableBatching(true) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() func makeEntity(position: simd_float3) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } if let localTransform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -1220,7 +1096,7 @@ final class StaticBatchingTest: BaseRenderSetup { } let evictionEvent = AssetResidencyChangedEvent( entityId: entityId, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -1242,11 +1118,6 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickDefersSomeDirtyCellsWhenWorkBudgetIsExceeded() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setMaxDirtyCellsPerTick(4) BatchingSystem.shared.setMaxRebuildVerticesPerTick(1_000_000_000) @@ -1254,18 +1125,13 @@ final class StaticBatchingTest: BaseRenderSetup { BatchingSystem.shared.setMaxRebuildBufferBytesPerTick(1) enableBatching(true) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() func makeEntity(position: simd_float3) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } if let localTransform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -1295,7 +1161,7 @@ final class StaticBatchingTest: BaseRenderSetup { } let evictionEvent = AssetResidencyChangedEvent( entityId: entityId, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -1314,11 +1180,6 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickMarksOversizedCellsRuntimeIneligible() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setMaxRuntimeCellVertices(1) BatchingSystem.shared.setMaxRuntimeCellIndices(1_000_000_000) @@ -1326,18 +1187,13 @@ final class StaticBatchingTest: BaseRenderSetup { BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(0) enableBatching(true) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() func makeEntity(position: simd_float3) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } if let localTransform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -1396,11 +1252,6 @@ final class StaticBatchingTest: BaseRenderSetup { } func testBackgroundArtifactBuildAppliesOnSubsequentTick() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setBackgroundArtifactBuildEnabled(true) BatchingSystem.shared.setMaxDirtyCellsPerTick(1) @@ -1409,18 +1260,13 @@ final class StaticBatchingTest: BaseRenderSetup { BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(0) enableBatching(true) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() func makeEntity(position: simd_float3) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } if let localTransform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -1443,7 +1289,7 @@ final class StaticBatchingTest: BaseRenderSetup { SystemEventBus.shared.queueResidencyChange( AssetResidencyChangedEvent( entityId: entityA, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -1469,28 +1315,18 @@ final class StaticBatchingTest: BaseRenderSetup { } func testVisibilityGateDefersOffscreenCellBatchRebuild() { - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setVisibilityGatedBatchBuildEnabled(true) BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(0) enableBatching(true) - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() func makeEntity(position: simd_float3) -> EntityID { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") renderComponent.assetName = "ball" } if let localTransform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -1514,7 +1350,7 @@ final class StaticBatchingTest: BaseRenderSetup { SystemEventBus.shared.queueResidencyChange( AssetResidencyChangedEvent( entityId: entityA, - assetURL: ballURL, + assetURL: URL(fileURLWithPath: "/dev/null/ball"), meshName: "ball", isResident: false ) @@ -1540,17 +1376,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testGenerateBatchesCreatesSeparateBatchesForDifferentLODs() { // Given: Create entities with same material but different LOD levels - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() // Create entities at LOD 0 var lod0Entities: [EntityID] = [] @@ -1558,7 +1384,7 @@ final class StaticBatchingTest: BaseRenderSetup { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { transform.position = simd_float3(Float(i) * 2.0, 0, 0) @@ -1577,7 +1403,7 @@ final class StaticBatchingTest: BaseRenderSetup { let entity = createEntity() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { transform.position = simd_float3(Float(i) * 2.0, 5, 0) @@ -1792,17 +1618,7 @@ final class StaticBatchingTest: BaseRenderSetup { // usdz-embedded:///embedded_Basecolor_map // Even with different mesh hosts, entities should still batch together // when they share the same source asset and embedded texture token. - guard let ballURL = getResourceURL(resourceName: "ball", ext: "usdz", subName: nil) else { - XCTFail("❌ Failed to load ball.usdz") - return - } - - let meshes = Mesh.loadMeshes( - url: ballURL, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - flip: true - ) + let meshes = BasicPrimitives.createSphere() var entities: [EntityID] = [] for i in 0 ..< 3 { @@ -1810,7 +1626,7 @@ final class StaticBatchingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = ballURL + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { diff --git a/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift b/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift index 4b2a26ad..24672f02 100644 --- a/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift +++ b/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift @@ -557,108 +557,7 @@ final class StreamLodBatchLODAwareStreamingTests: BaseRenderSetup { // MARK: - LOD+OOC Integration Tests -/// Tests that verify the LOD+OOC integration path in ProgressiveAssetLoader. -/// These test the CPU registry behaviour for LOD group entities without GPU or disk I/O. -@MainActor -final class StreamLodBatchOOCIntegrationTests: XCTestCase { - var loader: ProgressiveAssetLoader! - - override func setUp() async throws { - loader = ProgressiveAssetLoader.shared - loader.cancelAll() - guard let device = MTLCreateSystemDefaultDevice() else { - XCTFail("No Metal device") - return - } - renderInfo.device = device - } - - override func tearDown() async throws { - loader.cancelAll() - } - - private func makeEntry(name: String) -> ProgressiveAssetLoader.CPUMeshEntry { - guard let device = MTLCreateSystemDefaultDevice() else { - fatalError("No Metal device") - } - return ProgressiveAssetLoader.CPUMeshEntry( - object: MDLObject(), - vertexDescriptor: MDLVertexDescriptor(), - textureLoader: TextureLoader(device: device), - device: device, - url: URL(fileURLWithPath: "/dev/null"), - filename: "scene", - withExtension: "usdz", - uniqueAssetName: name, - estimatedGPUBytes: 0, - residencyPolicy: .fullLoad - ) - } - - func testHasCPULODData_detectsLODOOCEntity() { - let groupEntityId: EntityID = 5000 - loader.storeCPULODMesh(makeEntry(name: "Tree_LOD0"), for: groupEntityId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(name: "Tree_LOD1"), for: groupEntityId, lodIndex: 1) - - XCTAssertTrue(loader.hasCPULODData(for: groupEntityId), - "hasCPULODData must return true for a LOD+OOC entity") - } - - func testHasCPULODData_distinguishesFromRegularOOCEntity() { - let lodEntityId: EntityID = 5010 - let regularEntityId: EntityID = 5011 - - loader.storeCPULODMesh(makeEntry(name: "Tree_LOD0"), for: lodEntityId, lodIndex: 0) - loader.storeCPUMesh(makeEntry(name: "Building#0"), for: regularEntityId) - - XCTAssertTrue(loader.hasCPULODData(for: lodEntityId), - "LOD+OOC entity must report hasCPULODData = true") - XCTAssertFalse(loader.hasCPULODData(for: regularEntityId), - "Regular OOC entity must report hasCPULODData = false") - } - - func testLODOOCRegistration_multiGroupChildrenAreMappedToRoot() { - let rootId: EntityID = 5020 - let treeGroupId: EntityID = 5021 - let rockGroupId: EntityID = 5022 - - // Register LOD entries for two groups - loader.storeCPULODMesh(makeEntry(name: "Tree_LOD0"), for: treeGroupId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(name: "Tree_LOD1"), for: treeGroupId, lodIndex: 1) - loader.storeCPULODMesh(makeEntry(name: "Rock_LOD0"), for: rockGroupId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(name: "Rock_LOD1"), for: rockGroupId, lodIndex: 1) - loader.registerChildren([treeGroupId, rockGroupId], for: rootId) - - // Both groups should have LOD data - XCTAssertTrue(loader.hasCPULODData(for: treeGroupId)) - XCTAssertTrue(loader.hasCPULODData(for: rockGroupId)) - - // Children lookup should return both group entities - let children = loader.getChildren(for: rootId) - XCTAssertEqual(Set(children), Set([treeGroupId, rockGroupId])) - } - - func testLODOOCRegistration_allLevelsRestoredAfterCancelAll() { - let entityId: EntityID = 5030 - loader.storeCPULODMesh(makeEntry(name: "Prop_LOD0"), for: entityId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(name: "Prop_LOD1"), for: entityId, lodIndex: 1) - loader.storeCPULODMesh(makeEntry(name: "Prop_LOD2"), for: entityId, lodIndex: 2) - - loader.cancelAll() - - XCTAssertNil(loader.retrieveAllCPULODMeshes(for: entityId), - "cancelAll must clear all LOD registry entries") - XCTAssertFalse(loader.hasCPULODData(for: entityId)) - } - - func testLODStubLevelStartsNotResident() { - // Verify that a LODLevel created with empty mesh starts .notResident — - // the automatic residency state that the LOD+OOC path relies on. - let stubLevel = LODLevel(mesh: [], maxDistance: 50, url: URL(fileURLWithPath: "/dev/null"), assetName: "Tree_LOD0") - XCTAssertEqual(stubLevel.residencyState, .notResident, - "Stub LODLevel (empty mesh) must start as .notResident") - } -} +// Tests that verify the LOD+OOC integration path in ProgressiveAssetLoader. // MARK: - Region Streaming Event Tests diff --git a/Tests/UntoldEngineRenderTests/USDZTextureTest.swift b/Tests/UntoldEngineRenderTests/USDZTextureTest.swift index 2a251c66..b72531c9 100644 --- a/Tests/UntoldEngineRenderTests/USDZTextureTest.swift +++ b/Tests/UntoldEngineRenderTests/USDZTextureTest.swift @@ -36,7 +36,7 @@ final class USDZTextureTest: BaseRenderSetup { func test_loadUSDZModel_storesMDLTextureReferences() { // Create an entity and load the model let entity = createEntity() - setEntityMesh(entityId: entity, filename: "redplayer", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "redplayer") // When: Get the material guard let renderComponent = scene.get(component: RenderComponent.self, for: entity), @@ -59,7 +59,7 @@ final class USDZTextureTest: BaseRenderSetup { // Given: A USDZ model let entity = createEntity() - setEntityMesh(entityId: entity, filename: "redplayer", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "redplayer") // When: Get material texture URL if let baseColorURL = getMaterialTextureURL(entityId: entity, type: .baseColor) { @@ -91,7 +91,7 @@ final class USDZTextureTest: BaseRenderSetup { // Given: An entity with USDZ model let entity = createEntity() - setEntityMesh(entityId: entity, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "ball") // When/Then: Check if we can restore textures // Only check for texture types that actually exist in the model @@ -108,7 +108,7 @@ final class USDZTextureTest: BaseRenderSetup { // Given: An entity with USDZ model let entity = createEntity() - setEntityMesh(entityId: entity, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "ball") // Store original MDLTexture reference let originalMDLTexture = getMaterialMDLTexture(entityId: entity, type: .baseColor) @@ -129,7 +129,7 @@ final class USDZTextureTest: BaseRenderSetup { // Given: An entity with USDZ model let entity = createEntity() - setEntityMesh(entityId: entity, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "ball") // Store original URL let originalURL = getMaterialTextureURL(entityId: entity, type: .baseColor) @@ -153,7 +153,7 @@ final class USDZTextureTest: BaseRenderSetup { // Given: An entity with USDZ model and an external texture file let entity = createEntity() - setEntityMesh(entityId: entity, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "ball") // Store original MDLTexture reference let originalMDLTexture = getMaterialMDLTexture(entityId: entity, type: .baseColor) @@ -185,7 +185,7 @@ final class USDZTextureTest: BaseRenderSetup { // Given: An entity with USDZ model let entity = createEntity() - setEntityMesh(entityId: entity, filename: "ball", withExtension: "usdz") + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(), assetName: "ball") // When: Restore base color texture (should use sRGB) removeMaterialTexture(entityId: entity, textureType: .baseColor) diff --git a/Tests/UntoldEngineTests/ProgressiveAssetLoaderTests.swift b/Tests/UntoldEngineTests/ProgressiveAssetLoaderTests.swift index fb50a25f..3f118395 100644 --- a/Tests/UntoldEngineTests/ProgressiveAssetLoaderTests.swift +++ b/Tests/UntoldEngineTests/ProgressiveAssetLoaderTests.swift @@ -9,25 +9,20 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Metal -import MetalKit -import ModelIO +import simd @testable import UntoldEngine import XCTest -// MARK: - ProgressiveAssetLoader CPU Registry Tests +// MARK: - ProgressiveAssetLoader CPURuntimeEntry Registry Tests -/// Tests for ProgressiveAssetLoader's CPU mesh registry. +/// Tests for ProgressiveAssetLoader's native .untold CPU registry. /// -/// ProgressiveAssetLoader is now a pure CPU registry: it stores MDLMesh data -/// keyed by entity ID so GeometryStreamingSystem can upload each mesh on demand -/// without re-reading disk. The old per-frame job scheduler (ProgressiveLoadJob, -/// PendingObjectItem, tick() budget, round-robin) was removed; tick() is a -/// retained no-op for call-site compatibility. +/// ProgressiveAssetLoader stores CPURuntimeEntry (RuntimeAssetNode + metadata) keyed +/// by child entity ID so GeometryStreamingSystem can upload each node to Metal on demand +/// without re-reading disk. These tests verify all CRUD operations and lifecycle interactions. @MainActor final class ProgressiveAssetLoaderRegistryTests: XCTestCase { var loader: ProgressiveAssetLoader! - var device: MTLDevice! - var textureLoader: TextureLoader! override func setUp() async throws { loader = ProgressiveAssetLoader.shared @@ -37,30 +32,26 @@ final class ProgressiveAssetLoaderRegistryTests: XCTestCase { XCTFail("No Metal device available — skipping ProgressiveAssetLoader tests") return } - device = mtlDevice renderInfo.device = mtlDevice - textureLoader = TextureLoader(device: mtlDevice) } override func tearDown() async throws { loader.cancelAll() - device = nil - textureLoader = nil } // MARK: - Helpers - /// Builds a CPUMeshEntry with a plain MDLObject (no mesh data) and a known - /// estimatedGPUBytes value so registry round-trips can be verified. - private func makeEntry(estimatedGPUBytes: Int = 0) -> ProgressiveAssetLoader.CPUMeshEntry { - ProgressiveAssetLoader.CPUMeshEntry( - object: MDLObject(), - vertexDescriptor: MDLVertexDescriptor(), - textureLoader: textureLoader, - device: device, + private func makeEntry(estimatedGPUBytes: Int = 0) -> ProgressiveAssetLoader.CPURuntimeEntry { + let dummyNode = RuntimeAssetNode( + id: 0, + name: "TestNode", + localBounds: RuntimeAABB(min: .zero, max: simd_float3(1, 1, 1)), + worldBounds: RuntimeAABB(min: .zero, max: simd_float3(1, 1, 1)), + primitives: [] + ) + return ProgressiveAssetLoader.CPURuntimeEntry( + node: dummyNode, url: URL(fileURLWithPath: "/dev/null"), - filename: "test", - withExtension: "usdz", uniqueAssetName: "TestMesh#0", estimatedGPUBytes: estimatedGPUBytes, residencyPolicy: .fullLoad @@ -70,713 +61,166 @@ final class ProgressiveAssetLoaderRegistryTests: XCTestCase { // MARK: - tick() no-op func testTickIsNoOp() { - // tick() is retained only for call-site compatibility; it must not crash - // and must leave the registry untouched. let entityId: EntityID = 1 - loader.storeCPUMesh(makeEntry(), for: entityId) + loader.storeCPURuntimeEntry(makeEntry(), for: entityId) - loader.tick() loader.tick() loader.tick() - XCTAssertNotNil(loader.retrieveCPUMesh(for: entityId), + XCTAssertNotNil(loader.retrieveCPURuntimeEntry(for: entityId), "tick() must not clear CPU registry entries") } - // MARK: - storeCPUMesh / retrieveCPUMesh + // MARK: - storeCPURuntimeEntry / retrieveCPURuntimeEntry func testStoreThenRetrieve_returnsStoredEntry() { let entityId: EntityID = 10 - let entry = makeEntry(estimatedGPUBytes: 512_000) - loader.storeCPUMesh(entry, for: entityId) - - let retrieved = loader.retrieveCPUMesh(for: entityId) - XCTAssertNotNil(retrieved, "Entry should be present after store") + loader.storeCPURuntimeEntry(makeEntry(estimatedGPUBytes: 512_000), for: entityId) + XCTAssertNotNil(loader.retrieveCPURuntimeEntry(for: entityId), + "Entry should be present after store") } func testRetrieve_returnsNilForUnknownEntity() { - XCTAssertNil(loader.retrieveCPUMesh(for: 99999), + XCTAssertNil(loader.retrieveCPURuntimeEntry(for: 99999), "Retrieving an unknown entity ID should return nil") } func testStore_overwritesPreviousEntry() { let entityId: EntityID = 20 - loader.storeCPUMesh(makeEntry(estimatedGPUBytes: 100), for: entityId) - loader.storeCPUMesh(makeEntry(estimatedGPUBytes: 200), for: entityId) - - let retrieved = loader.retrieveCPUMesh(for: entityId) - XCTAssertEqual(retrieved?.estimatedGPUBytes, 200, + loader.storeCPURuntimeEntry(makeEntry(estimatedGPUBytes: 100), for: entityId) + loader.storeCPURuntimeEntry(makeEntry(estimatedGPUBytes: 200), for: entityId) + XCTAssertEqual(loader.retrieveCPURuntimeEntry(for: entityId)?.estimatedGPUBytes, 200, "Second store should overwrite the first") } - // MARK: - estimatedGPUBytes round-trip - - func testEstimatedGPUBytes_survivesStoreRetrieveRoundTrip() { + func testEstimatedGPUBytes_survivesRoundTrip() { let entityId: EntityID = 30 let expectedBytes = 1_234_567 - loader.storeCPUMesh(makeEntry(estimatedGPUBytes: expectedBytes), for: entityId) - - let retrieved = loader.retrieveCPUMesh(for: entityId) - XCTAssertEqual(retrieved?.estimatedGPUBytes, expectedBytes, - "estimatedGPUBytes must survive the store / retrieve round-trip") + loader.storeCPURuntimeEntry(makeEntry(estimatedGPUBytes: expectedBytes), for: entityId) + XCTAssertEqual(loader.retrieveCPURuntimeEntry(for: entityId)?.estimatedGPUBytes, expectedBytes) } func testEstimatedGPUBytes_zeroIsValid() { let entityId: EntityID = 31 - loader.storeCPUMesh(makeEntry(estimatedGPUBytes: 0), for: entityId) - XCTAssertEqual(loader.retrieveCPUMesh(for: entityId)?.estimatedGPUBytes, 0) + loader.storeCPURuntimeEntry(makeEntry(estimatedGPUBytes: 0), for: entityId) + XCTAssertEqual(loader.retrieveCPURuntimeEntry(for: entityId)?.estimatedGPUBytes, 0) } - // MARK: - removeCPUMesh + // MARK: - hasCPURuntimeData - func testRemoveCPUMesh_entryIsAbsentAfterRemoval() { + func testHasCPURuntimeData_trueAfterStore() { let entityId: EntityID = 40 - loader.storeCPUMesh(makeEntry(), for: entityId) - XCTAssertNotNil(loader.retrieveCPUMesh(for: entityId)) + XCTAssertFalse(loader.hasCPURuntimeData(for: entityId)) + loader.storeCPURuntimeEntry(makeEntry(), for: entityId) + XCTAssertTrue(loader.hasCPURuntimeData(for: entityId)) + } - loader.removeCPUMesh(for: entityId) - XCTAssertNil(loader.retrieveCPUMesh(for: entityId), - "Entry should be absent after removeCPUMesh") + func testHasCPURuntimeData_falseForUnknownEntity() { + XCTAssertFalse(loader.hasCPURuntimeData(for: 88888)) } - func testRemoveCPUMesh_unknownEntityIsNoOp() { - // Must not crash when removing an entity that was never stored. - loader.removeCPUMesh(for: 88888) + // MARK: - removeCPURuntimeEntry + + func testRemoveCPURuntimeEntry_entryIsAbsentAfterRemoval() { + let entityId: EntityID = 50 + loader.storeCPURuntimeEntry(makeEntry(), for: entityId) + XCTAssertTrue(loader.hasCPURuntimeData(for: entityId)) + + loader.removeCPURuntimeEntry(for: entityId) + XCTAssertFalse(loader.hasCPURuntimeData(for: entityId)) + XCTAssertNil(loader.retrieveCPURuntimeEntry(for: entityId)) + } + + func testRemoveCPURuntimeEntry_unknownEntityIsNoOp() { + loader.removeCPURuntimeEntry(for: 88888) // Must not crash } - func testRemoveCPUMesh_doesNotAffectOtherEntries() { - let keepId: EntityID = 50 - let removeId: EntityID = 51 - loader.storeCPUMesh(makeEntry(), for: keepId) - loader.storeCPUMesh(makeEntry(), for: removeId) + func testRemoveCPURuntimeEntry_doesNotAffectOtherEntries() { + let keepId: EntityID = 60 + let removeId: EntityID = 61 + loader.storeCPURuntimeEntry(makeEntry(), for: keepId) + loader.storeCPURuntimeEntry(makeEntry(), for: removeId) - loader.removeCPUMesh(for: removeId) + loader.removeCPURuntimeEntry(for: removeId) - XCTAssertNotNil(loader.retrieveCPUMesh(for: keepId), - "Removing one entity must not affect other registry entries") - XCTAssertNil(loader.retrieveCPUMesh(for: removeId)) + XCTAssertTrue(loader.hasCPURuntimeData(for: keepId)) + XCTAssertFalse(loader.hasCPURuntimeData(for: removeId)) } // MARK: - cancelAll - func testCancelAll_clearsCPURegistry() { - loader.storeCPUMesh(makeEntry(), for: 60) - loader.storeCPUMesh(makeEntry(), for: 61) - loader.storeCPUMesh(makeEntry(), for: 62) + func testCancelAll_clearsCPURuntimeRegistry() { + loader.storeCPURuntimeEntry(makeEntry(), for: 70) + loader.storeCPURuntimeEntry(makeEntry(), for: 71) loader.cancelAll() - XCTAssertNil(loader.retrieveCPUMesh(for: 60)) - XCTAssertNil(loader.retrieveCPUMesh(for: 61)) - XCTAssertNil(loader.retrieveCPUMesh(for: 62)) + XCTAssertFalse(loader.hasCPURuntimeData(for: 70)) + XCTAssertFalse(loader.hasCPURuntimeData(for: 71)) } func testCancelAll_onEmptyRegistryIsNoOp() { - // Must not crash when called on an already-empty registry. - loader.cancelAll() loader.cancelAll() + loader.cancelAll() // Must not crash } // MARK: - registerChildren / removeOutOfCoreAsset func testRemoveOutOfCoreAsset_removesRegisteredChildren() { - let rootId: EntityID = 70 - let childIds: [EntityID] = [71, 72, 73] + let rootId: EntityID = 80 + let childIds: [EntityID] = [81, 82, 83] for id in childIds { - loader.storeCPUMesh(makeEntry(), for: id) + loader.storeCPURuntimeEntry(makeEntry(), for: id) } loader.registerChildren(childIds, for: rootId) - loader.removeOutOfCoreAsset(rootEntityId: rootId) for id in childIds { - XCTAssertNil(loader.retrieveCPUMesh(for: id), - "Child \(id) should be cleared after removeOutOfCoreAsset") + XCTAssertFalse(loader.hasCPURuntimeData(for: id), + "Child \(id) should be cleared after removeOutOfCoreAsset") } } func testRemoveOutOfCoreAsset_doesNotAffectUnrelatedEntries() { - let rootId: EntityID = 80 - let childId: EntityID = 81 - let otherId: EntityID = 82 + let rootId: EntityID = 90 + let childId: EntityID = 91 + let otherId: EntityID = 92 - loader.storeCPUMesh(makeEntry(), for: childId) - loader.storeCPUMesh(makeEntry(), for: otherId) + loader.storeCPURuntimeEntry(makeEntry(), for: childId) + loader.storeCPURuntimeEntry(makeEntry(), for: otherId) loader.registerChildren([childId], for: rootId) loader.removeOutOfCoreAsset(rootEntityId: rootId) - XCTAssertNil(loader.retrieveCPUMesh(for: childId), - "Registered child should be removed") - XCTAssertNotNil(loader.retrieveCPUMesh(for: otherId), - "Unrelated entry must not be removed") + XCTAssertFalse(loader.hasCPURuntimeData(for: childId), "Registered child should be removed") + XCTAssertTrue(loader.hasCPURuntimeData(for: otherId), "Unrelated entry must not be removed") } func testRemoveOutOfCoreAsset_unknownRootIsNoOp() { - // Must not crash when called for a root that was never registered. - loader.removeOutOfCoreAsset(rootEntityId: 99000) + loader.removeOutOfCoreAsset(rootEntityId: 99000) // Must not crash } func testRemoveOutOfCoreAsset_calledTwiceIsIdempotent() { - let rootId: EntityID = 90 - let childId: EntityID = 91 - loader.storeCPUMesh(makeEntry(), for: childId) + let rootId: EntityID = 100 + let childId: EntityID = 101 + loader.storeCPURuntimeEntry(makeEntry(), for: childId) loader.registerChildren([childId], for: rootId) loader.removeOutOfCoreAsset(rootEntityId: rootId) - loader.removeOutOfCoreAsset(rootEntityId: rootId) // second call must not crash - } - - // MARK: - Configuration - - func testFileSizeThresholdBytesDefaultIs50MB() { - XCTAssertEqual(loader.fileSizeThresholdBytes, 50 * 1024 * 1024) - } - - func testOutOfCoreObjectCountThresholdDefaultIs50() { - XCTAssertEqual(loader.outOfCoreObjectCountThreshold, 50) - } - - func testConfigurationPropertiesAreWritable() { - let originalSize = loader.fileSizeThresholdBytes - let originalCount = loader.outOfCoreObjectCountThreshold - - loader.fileSizeThresholdBytes = 100 * 1024 * 1024 - loader.outOfCoreObjectCountThreshold = 200 - - XCTAssertEqual(loader.fileSizeThresholdBytes, 100 * 1024 * 1024) - XCTAssertEqual(loader.outOfCoreObjectCountThreshold, 200) - - // Restore defaults so other tests are not affected. - loader.fileSizeThresholdBytes = originalSize - loader.outOfCoreObjectCountThreshold = originalCount - } - - // MARK: - textureLoadingEnabled flag - - func testTextureLoadingEnabledDefaultIsTrue() { - XCTAssertTrue(loader.textureLoadingEnabled, "Texture loading should be enabled by default") - } - - func testTextureLoadingEnabledIsWritable() { - loader.textureLoadingEnabled = false - XCTAssertFalse(loader.textureLoadingEnabled) - loader.textureLoadingEnabled = true - XCTAssertTrue(loader.textureLoadingEnabled) - } - - func testEnsureTexturesLoaded_skipsLoadWhenDisabled() { - // Register a fake asset reference so ensureTexturesLoaded would normally call loadTextures() - // (with textureLoadingEnabled = false it must not crash, and assetTexturesLoaded - // must remain empty so a future re-enable actually calls loadTextures()). - let rootId: EntityID = 9999 - - loader.textureLoadingEnabled = false - // Should not crash; no textures loaded. - loader.acquireAssetTextureLock(for: rootId) - loader.ensureTexturesLoaded(for: rootId) - loader.releaseAssetTextureLock(for: rootId) - - // After re-enabling, a second call should be allowed (entity not in assetTexturesLoaded). - loader.textureLoadingEnabled = true - // Just validate no crash and flag is restored. - XCTAssertTrue(loader.textureLoadingEnabled) - - loader.textureLoadingEnabled = true // restore - } -} - -// MARK: - V2 Warm/Cold Residency Lifecycle Tests - -/// Tests for the warm/cold residency lifecycle introduced in V2. -/// -/// CPU-warm: MDLAsset + CPUMeshEntry objects are alive in the registry. -/// CPU-cold: MDLAsset released; rehydration context (URL + policy) retained for re-parse. -/// -/// These tests are purely CPU-side — no GPU, no disk I/O. -@MainActor -final class ProgressiveAssetLoaderWarmColdTests: XCTestCase { - var loader: ProgressiveAssetLoader! - var device: MTLDevice! - var textureLoader: TextureLoader! - - override func setUp() async throws { - loader = ProgressiveAssetLoader.shared - loader.cancelAll() - - guard let mtlDevice = MTLCreateSystemDefaultDevice() else { - XCTFail("No Metal device available") - return - } - device = mtlDevice - renderInfo.device = mtlDevice - textureLoader = TextureLoader(device: mtlDevice) + loader.removeOutOfCoreAsset(rootEntityId: rootId) // Must not crash } - override func tearDown() async throws { - loader.cancelAll() - device = nil - textureLoader = nil - } - - // MARK: - Helpers - - private func makeEntry(estimatedGPUBytes: Int = 0) -> ProgressiveAssetLoader.CPUMeshEntry { - ProgressiveAssetLoader.CPUMeshEntry( - object: MDLObject(), - vertexDescriptor: MDLVertexDescriptor(), - textureLoader: textureLoader, - device: device, - url: URL(fileURLWithPath: "/dev/null"), - filename: "test", - withExtension: "usdz", - uniqueAssetName: "TestMesh#0", - estimatedGPUBytes: estimatedGPUBytes, - residencyPolicy: .fullLoad - ) - } - - // MARK: - Test 1: isColdRoot returns false before releaseWarmAsset - - func testIsColdRoot_falseBeforeRelease() { - let rootId: EntityID = 200 - loader.registerChildren([201, 202], for: rootId) - XCTAssertFalse(loader.isColdRoot(rootId), - "Asset should start warm (isColdRoot must be false before releaseWarmAsset)") - } - - // MARK: - Test 2: releaseWarmAsset transitions to cold - - func testReleaseWarmAsset_transitionsToCold() { - let rootId: EntityID = 210 - let childIds: [EntityID] = [211, 212] - - for id in childIds { - loader.storeCPUMesh(makeEntry(), for: id) - } - loader.registerChildren(childIds, for: rootId) - - loader.releaseWarmAsset(rootEntityId: rootId) - - XCTAssertTrue(loader.isColdRoot(rootId), - "isColdRoot must be true after releaseWarmAsset") - } - - // MARK: - Test 3: releaseWarmAsset clears child CPU entries - - func testReleaseWarmAsset_clearsCPUEntriesForChildren() { - let rootId: EntityID = 220 - let childIds: [EntityID] = [221, 222, 223] - - for id in childIds { - loader.storeCPUMesh(makeEntry(estimatedGPUBytes: 1024), for: id) - } - loader.registerChildren(childIds, for: rootId) - - loader.releaseWarmAsset(rootEntityId: rootId) - - for id in childIds { - XCTAssertNil(loader.retrieveCPUMesh(for: id), - "Child \(id) CPUMeshEntry must be cleared after releaseWarmAsset") - } - } - - // MARK: - Test 4: rehydration context survives releaseWarmAsset - - func testRehydrationContext_survivesReleaseWarmAsset() { - let rootId: EntityID = 230 - let testURL = URL(fileURLWithPath: "/tmp/test.usdz") - let policy = AssetLoadingPolicy.fullLoad - - loader.registerChildren([231], for: rootId) - loader.storeRootRehydrationContext(url: testURL, policy: policy, for: rootId) - loader.releaseWarmAsset(rootEntityId: rootId) - - let context = loader.rehydrationContext(for: rootId) - XCTAssertNotNil(context, "Rehydration context must survive releaseWarmAsset") - XCTAssertEqual(context?.url, testURL, "Rehydration context URL must be preserved") - } - - // MARK: - Test 5: markAsWarm restores warm state - - func testMarkAsWarm_restoringWarmStateAfterCold() { - let rootId: EntityID = 240 - loader.registerChildren([241], for: rootId) - loader.releaseWarmAsset(rootEntityId: rootId) - - XCTAssertTrue(loader.isColdRoot(rootId), "Pre-condition: must be cold") - - loader.markAsWarm(rootEntityId: rootId) - - XCTAssertFalse(loader.isColdRoot(rootId), - "isColdRoot must be false after markAsWarm") - } - - // MARK: - Test 6: getOrCreateRehydrationTask returns same task for concurrent calls - - func testGetOrCreateRehydrationTask_factoryCalledOnlyOnceForDuplicateCalls() { - let rootId: EntityID = 250 - var factoryCallCount = 0 - - let task1 = loader.getOrCreateRehydrationTask(for: rootId) { - factoryCallCount += 1 - return Task { true } - } - _ = loader.getOrCreateRehydrationTask(for: rootId) { - factoryCallCount += 1 - return Task { true } - } - - XCTAssertEqual(factoryCallCount, 1, - "Factory must be called exactly once even when called twice for the same root") - - loader.clearRehydrationTask(for: rootId) - task1.cancel() - } - - // MARK: - Test 7: clearRehydrationTask causes next call to create a new task - - func testClearRehydrationTask_allowsNewTaskOnNextCall() { - let rootId: EntityID = 260 - var factoryCallCount = 0 - - let task1 = loader.getOrCreateRehydrationTask(for: rootId) { - factoryCallCount += 1 - return Task { false } - } - task1.cancel() - loader.clearRehydrationTask(for: rootId) - - let task2 = loader.getOrCreateRehydrationTask(for: rootId) { - factoryCallCount += 1 - return Task { true } - } - XCTAssertEqual(factoryCallCount, 2, - "After clearRehydrationTask, factory must be called again for the same root") - - loader.clearRehydrationTask(for: rootId) - task2.cancel() - } - - // MARK: - Test 8: removeOutOfCoreAsset clears cold state - - func testRemoveOutOfCoreAsset_clearsColdState() { - let rootId: EntityID = 270 - let testURL = URL(fileURLWithPath: "/tmp/remove_test.usdz") - - loader.registerChildren([271], for: rootId) - loader.storeRootRehydrationContext(url: testURL, policy: .fullLoad, for: rootId) - loader.releaseWarmAsset(rootEntityId: rootId) - - XCTAssertTrue(loader.isColdRoot(rootId), "Pre-condition: must be cold") - - loader.removeOutOfCoreAsset(rootEntityId: rootId) - - XCTAssertFalse(loader.isColdRoot(rootId), - "removeOutOfCoreAsset must clear the cold state") - XCTAssertNil(loader.rehydrationContext(for: rootId), - "removeOutOfCoreAsset must remove the rehydration context") - } - - // MARK: - Test 9: cancelAll clears all cold state - - func testCancelAll_clearsAllColdState() { - let rootIds: [EntityID] = [280, 281, 282] - for rootId in rootIds { - loader.registerChildren([rootId + 100], for: rootId) - loader.storeRootRehydrationContext( - url: URL(fileURLWithPath: "/tmp/asset_\(rootId).usdz"), - policy: .fullLoad, - for: rootId - ) - loader.releaseWarmAsset(rootEntityId: rootId) - } - - loader.cancelAll() - - for rootId in rootIds { - XCTAssertFalse(loader.isColdRoot(rootId), - "cancelAll must clear cold state for root \(rootId)") - XCTAssertNil(loader.rehydrationContext(for: rootId), - "cancelAll must remove rehydration context for root \(rootId)") - } - } - - // MARK: - Test 10: getChildren returns children in registration order + // MARK: - getChildren func testGetChildren_returnsChildrenInRegistrationOrder() { - let rootId: EntityID = 290 - let childIds: [EntityID] = [291, 292, 293, 294, 295] + let rootId: EntityID = 110 + let childIds: [EntityID] = [111, 112, 113] loader.registerChildren(childIds, for: rootId) - - let retrieved = loader.getChildren(for: rootId) - XCTAssertEqual(retrieved, childIds, - "getChildren must return children in the same order they were registered") + XCTAssertEqual(loader.getChildren(for: rootId), childIds) } func testGetChildren_returnsEmptyForUnknownRoot() { - XCTAssertTrue(loader.getChildren(for: 99999).isEmpty, - "getChildren must return empty array for an unregistered root") - } - - // MARK: - Test 11: releaseWarmAsset on already-cold root is a no-op - - func testReleaseWarmAsset_calledTwiceIsNoOp() { - let rootId: EntityID = 300 - loader.registerChildren([301, 302], for: rootId) - loader.releaseWarmAsset(rootEntityId: rootId) - loader.releaseWarmAsset(rootEntityId: rootId) // Must not crash or corrupt state - - XCTAssertTrue(loader.isColdRoot(rootId), - "Root should remain cold after a redundant releaseWarmAsset call") - } -} - -// MARK: - LOD CPU Registry Tests - -/// Tests for ProgressiveAssetLoader's LOD CPU registry (cpuLODRegistry). -/// -/// The LOD+OOC path stores one CPUMeshEntry per LOD level per group entity in -/// cpuLODRegistry, keyed by (EntityID, lodIndex). These tests verify all CRUD -/// operations and lifecycle interactions (releaseWarmAsset, removeOutOfCoreAsset, cancelAll). -@MainActor -final class ProgressiveAssetLoaderLODRegistryTests: XCTestCase { - var loader: ProgressiveAssetLoader! - var device: MTLDevice! - var textureLoader: TextureLoader! - - override func setUp() async throws { - loader = ProgressiveAssetLoader.shared - loader.cancelAll() - guard let mtlDevice = MTLCreateSystemDefaultDevice() else { - XCTFail("No Metal device available") - return - } - device = mtlDevice - renderInfo.device = mtlDevice - textureLoader = TextureLoader(device: mtlDevice) - } - - override func tearDown() async throws { - loader.cancelAll() - device = nil - textureLoader = nil - } - - private func makeEntry(uniqueAssetName: String = "TestMesh_LOD0", estimatedGPUBytes: Int = 0) -> ProgressiveAssetLoader.CPUMeshEntry { - ProgressiveAssetLoader.CPUMeshEntry( - object: MDLObject(), - vertexDescriptor: MDLVertexDescriptor(), - textureLoader: textureLoader, - device: device, - url: URL(fileURLWithPath: "/dev/null"), - filename: "test", - withExtension: "usdz", - uniqueAssetName: uniqueAssetName, - estimatedGPUBytes: estimatedGPUBytes, - residencyPolicy: .fullLoad - ) - } - - // MARK: - storeCPULODMesh / retrieveCPULODMesh - - func testStoreThenRetrieve_returnsStoredLODEntry() { - let entityId: EntityID = 1000 - let entry = makeEntry(uniqueAssetName: "Tree_LOD0", estimatedGPUBytes: 256_000) - loader.storeCPULODMesh(entry, for: entityId, lodIndex: 0) - - let retrieved = loader.retrieveCPULODMesh(for: entityId, lodIndex: 0) - XCTAssertNotNil(retrieved, "LOD entry should be present after store") - XCTAssertEqual(retrieved?.uniqueAssetName, "Tree_LOD0") - XCTAssertEqual(retrieved?.estimatedGPUBytes, 256_000) - } - - func testRetrieve_returnsNilForUnknownEntityOrLOD() { - XCTAssertNil(loader.retrieveCPULODMesh(for: 99999, lodIndex: 0), - "Unknown entity should return nil") - - let entityId: EntityID = 1001 - loader.storeCPULODMesh(makeEntry(), for: entityId, lodIndex: 0) - XCTAssertNil(loader.retrieveCPULODMesh(for: entityId, lodIndex: 5), - "Unknown LOD index should return nil") - } - - func testStore_multipleLODLevelsForSameEntity() { - let entityId: EntityID = 1002 - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD0"), for: entityId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD1"), for: entityId, lodIndex: 1) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD2"), for: entityId, lodIndex: 2) - - XCTAssertEqual(loader.retrieveCPULODMesh(for: entityId, lodIndex: 0)?.uniqueAssetName, "Tree_LOD0") - XCTAssertEqual(loader.retrieveCPULODMesh(for: entityId, lodIndex: 1)?.uniqueAssetName, "Tree_LOD1") - XCTAssertEqual(loader.retrieveCPULODMesh(for: entityId, lodIndex: 2)?.uniqueAssetName, "Tree_LOD2") - } - - func testStore_overwritesPreviousEntryForSameLODIndex() { - let entityId: EntityID = 1003 - loader.storeCPULODMesh(makeEntry(estimatedGPUBytes: 100), for: entityId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(estimatedGPUBytes: 200), for: entityId, lodIndex: 0) - - XCTAssertEqual(loader.retrieveCPULODMesh(for: entityId, lodIndex: 0)?.estimatedGPUBytes, 200, - "Second store should overwrite the first for the same LOD index") - } - - // MARK: - retrieveAllCPULODMeshes - - func testRetrieveAll_returnsAllStoredLevels() { - let entityId: EntityID = 1010 - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Rock_LOD0"), for: entityId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Rock_LOD1"), for: entityId, lodIndex: 1) - - let all = loader.retrieveAllCPULODMeshes(for: entityId) - XCTAssertNotNil(all, "Should return a dictionary when entries exist") - XCTAssertEqual(all?.count, 2, "Should have 2 LOD entries") - XCTAssertEqual(all?[0]?.uniqueAssetName, "Rock_LOD0") - XCTAssertEqual(all?[1]?.uniqueAssetName, "Rock_LOD1") - } - - func testRetrieveAll_returnsNilForUnknownEntity() { - XCTAssertNil(loader.retrieveAllCPULODMeshes(for: 99998), - "Unknown entity should return nil") - } - - // MARK: - hasCPULODData - - func testHasCPULODData_trueAfterStoring() { - let entityId: EntityID = 1020 - XCTAssertFalse(loader.hasCPULODData(for: entityId), "Should be false before any store") - loader.storeCPULODMesh(makeEntry(), for: entityId, lodIndex: 0) - XCTAssertTrue(loader.hasCPULODData(for: entityId), "Should be true after storing a LOD entry") - } - - func testHasCPULODData_falseForRegularCPUMeshEntry() { - let entityId: EntityID = 1021 - // Storing in cpuMeshRegistry (not cpuLODRegistry) must not affect hasCPULODData - loader.storeCPUMesh(makeEntry(), for: entityId) - XCTAssertFalse(loader.hasCPULODData(for: entityId), - "hasCPULODData should remain false for regular OOC entries") - } - - func testHasCPULODData_falseAfterRemoveCPULODEntry() { - let entityId: EntityID = 1022 - loader.storeCPULODMesh(makeEntry(), for: entityId, lodIndex: 0) - XCTAssertTrue(loader.hasCPULODData(for: entityId)) - - loader.removeCPULODEntry(for: entityId) - XCTAssertFalse(loader.hasCPULODData(for: entityId), - "hasCPULODData should be false after removeCPULODEntry") - } - - // MARK: - removeCPULODEntry - - func testRemoveCPULODEntry_removesAllLevels() { - let entityId: EntityID = 1030 - loader.storeCPULODMesh(makeEntry(), for: entityId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(), for: entityId, lodIndex: 1) - loader.storeCPULODMesh(makeEntry(), for: entityId, lodIndex: 2) - - loader.removeCPULODEntry(for: entityId) - - XCTAssertNil(loader.retrieveCPULODMesh(for: entityId, lodIndex: 0)) - XCTAssertNil(loader.retrieveCPULODMesh(for: entityId, lodIndex: 1)) - XCTAssertNil(loader.retrieveCPULODMesh(for: entityId, lodIndex: 2)) - } - - func testRemoveCPULODEntry_unknownEntityIsNoOp() { - loader.removeCPULODEntry(for: 99997) // Must not crash - } - - func testRemoveCPULODEntry_doesNotAffectOtherEntities() { - let keepId: EntityID = 1040 - let removeId: EntityID = 1041 - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD0"), for: keepId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Rock_LOD0"), for: removeId, lodIndex: 0) - - loader.removeCPULODEntry(for: removeId) - - XCTAssertNotNil(loader.retrieveCPULODMesh(for: keepId, lodIndex: 0), - "Removing one entity's LOD data must not affect other entities") - } - - // MARK: - releaseWarmAsset clears LOD entries for children - - func testReleaseWarmAsset_clearsCPULODEntriesForChildren() { - let rootId: EntityID = 1050 - let groupId: EntityID = 1051 - - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD0"), for: groupId, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD1"), for: groupId, lodIndex: 1) - loader.registerChildren([groupId], for: rootId) - - loader.releaseWarmAsset(rootEntityId: rootId) - - XCTAssertNil(loader.retrieveCPULODMesh(for: groupId, lodIndex: 0), - "LOD entry for LOD0 should be cleared after releaseWarmAsset") - XCTAssertNil(loader.retrieveCPULODMesh(for: groupId, lodIndex: 1), - "LOD entry for LOD1 should be cleared after releaseWarmAsset") - XCTAssertFalse(loader.hasCPULODData(for: groupId), - "hasCPULODData should be false after releaseWarmAsset") - } - - // MARK: - removeOutOfCoreAsset clears LOD entries - - func testRemoveOutOfCoreAsset_clearsCPULODEntriesForChildren() { - let rootId: EntityID = 1060 - let groupId1: EntityID = 1061 - let groupId2: EntityID = 1062 - - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Tree_LOD0"), for: groupId1, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "Rock_LOD0"), for: groupId2, lodIndex: 0) - loader.registerChildren([groupId1, groupId2], for: rootId) - - loader.removeOutOfCoreAsset(rootEntityId: rootId) - - XCTAssertFalse(loader.hasCPULODData(for: groupId1), - "Group 1 LOD data should be cleared after removeOutOfCoreAsset") - XCTAssertFalse(loader.hasCPULODData(for: groupId2), - "Group 2 LOD data should be cleared after removeOutOfCoreAsset") - } - - // MARK: - cancelAll clears LOD registry - - func testCancelAll_clearsCPULODRegistry() { - let entityId1: EntityID = 1070 - let entityId2: EntityID = 1071 - loader.storeCPULODMesh(makeEntry(), for: entityId1, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(), for: entityId2, lodIndex: 0) - loader.storeCPULODMesh(makeEntry(), for: entityId2, lodIndex: 1) - - loader.cancelAll() - - XCTAssertFalse(loader.hasCPULODData(for: entityId1), - "cancelAll must clear all LOD registry entries") - XCTAssertFalse(loader.hasCPULODData(for: entityId2), - "cancelAll must clear all LOD registry entries") - } - - // MARK: - LOD and regular OOC registries are independent - - func testLODAndRegularRegistriesAreIndependent() { - let entityId: EntityID = 1080 - loader.storeCPUMesh(makeEntry(uniqueAssetName: "Regular#0"), for: entityId) - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "LOD_LOD0"), for: entityId, lodIndex: 0) - - // Both should be independently accessible - XCTAssertNotNil(loader.retrieveCPUMesh(for: entityId), - "Regular OOC entry should be accessible independently") - XCTAssertNotNil(loader.retrieveCPULODMesh(for: entityId, lodIndex: 0), - "LOD OOC entry should be accessible independently") - - // Removing from LOD registry must not affect regular registry - loader.removeCPULODEntry(for: entityId) - XCTAssertNotNil(loader.retrieveCPUMesh(for: entityId), - "Regular OOC entry must survive removeCPULODEntry") - - // Removing from regular registry must not affect LOD registry data - loader.storeCPULODMesh(makeEntry(uniqueAssetName: "LOD_LOD0"), for: entityId, lodIndex: 0) - loader.removeCPUMesh(for: entityId) - XCTAssertTrue(loader.hasCPULODData(for: entityId), - "LOD OOC data must survive removeCPUMesh") + XCTAssertTrue(loader.getChildren(for: 99999).isEmpty) } } diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index c3d4c7a3..c7c88a2e 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -250,7 +250,7 @@ setEntityStreamScene(entityId: sceneRoot, manifest: "dungeon", withExtension: "j ## Loading a Remote Streamed Scene -To streame a remote scene, you use the same function `setEntityStreamedScene()` but provide a url to your manifest json file. +To stream a remote scene, use the same `setEntityStreamScene(...)` API with a URL to your manifest JSON file. ```swift // Remote manifest (downloaded and cached on demand) diff --git a/docs/API/UsageExamples.md b/docs/API/UsageExamples.md index c839b192..2143999b 100644 --- a/docs/API/UsageExamples.md +++ b/docs/API/UsageExamples.md @@ -7,7 +7,7 @@ For a broader first project walkthrough, see [Getting Started](GettingStarted.md ## Export Assets First -Untold Engine uses `.untold` as its preferred runtime asset format. Keep USD/USDZ +Untold Engine uses `.untold` as its runtime asset format. Keep USD/USDZ as your authoring format, then export it before loading it in the engine. Convert one asset: diff --git a/docs/API/UsingAnimationSystem.md b/docs/API/UsingAnimationSystem.md index cee7db35..c8b6c5ce 100644 --- a/docs/API/UsingAnimationSystem.md +++ b/docs/API/UsingAnimationSystem.md @@ -20,9 +20,8 @@ let redPlayer = createEntity() Load your rigged model's `.untold` runtime asset and link it to the entity. This step ensures the entity is visually represented in the scene. ```swift -setEntityMesh(entityId: redPlayer, filename: "redplayer", withExtension: "untold", flip: false) +setEntityMesh(entityId: redPlayer, filename: "redplayer", withExtension: "untold") ``` ->>> Note: If your model renders with the wrong orientation, set the flip parameter to false. --- diff --git a/docs/API/UsingAsyncLoading.md b/docs/API/UsingAsyncLoading.md index e5fcb5ad..bd5d2692 100644 --- a/docs/API/UsingAsyncLoading.md +++ b/docs/API/UsingAsyncLoading.md @@ -1,6 +1,6 @@ # Async Loading System -UntoldEngine loads meshes asynchronously so scene setup does not stall the render loop. Use native `.untold` assets for runtime geometry; legacy USD/USDZ runtime paths are still supported. +UntoldEngine loads meshes asynchronously so scene setup does not stall the render loop. Use native `.untold` assets for runtime geometry. USD/USDZ files remain authoring/import inputs for the exporter, but runtime mesh loading and streaming use `.untold`. ## What the API Does @@ -24,7 +24,7 @@ The completion `Bool` is a **success flag**: - `true`: the asset loaded and registered successfully - `false`: loading failed and the engine fell back to the default placeholder mesh -It does **not** indicate whether the asset used an out-of-core path. +For ordinary public use, this API loads the asset immediately into GPU residency. It does not opt the asset into tile streaming. ## Scene Readiness Guard @@ -49,12 +49,25 @@ For ordinary `setEntityMeshAsync(...)` and `setEntityStreamScene(...)` flows, th | Use case | API | |---|---| +| Small asset needed immediately on the next line | `setEntityMesh(...)` | | Single always-resident asset | `setEntityMeshAsync(...)` | | Large streamed world | `setEntityStreamScene(...)` | +### Synchronous always-resident asset + +Use `setEntityMesh` when setup code needs the mesh registered before the next statement runs: + +```swift +let player = createEntity() +setEntityMesh(entityId: player, filename: "player", withExtension: "untold") +translateTo(entityId: player, position: simd_float3(0, 0, 0)) +``` + +This is a blocking immediate load. Do not use it for large runtime assets or tile-streamed worlds. + ### Always-resident asset -Use `setEntityMeshAsync` for props, characters, gameplay objects, HUD meshes, and any asset that should stay resident. +Use `setEntityMeshAsync` for props, characters, gameplay objects, HUD meshes, and any asset that should stay resident but can load off the main setup path. ```swift setEntityMeshAsync( @@ -100,12 +113,11 @@ This is the public streaming workflow. Do not build app-level streaming logic ar ## `streamingPolicy` -`setEntityMeshAsync` accepts a `streamingPolicy` parameter to control how geometry -is uploaded to the GPU. For standalone assets, two values are relevant: +`setEntityMeshAsync` accepts a `streamingPolicy` parameter, but the public contract is intentionally narrow: -- `.auto` — default; the engine chooses full upload or incremental based on asset size -- `.immediate` — always uploads in a single pass; use for props that must appear fully - formed on first frame (player characters, weapons, HUD objects) +- `.immediate` — default for `setEntityMeshAsync`; uploads in one pass and leaves the asset GPU-resident. +- `.auto` — used by `setEntityStreamScene(...)` internally for tile payloads so the runtime can choose full-tile upload vs OCC stub registration. +- `.outOfCore` — internal/specialized tile OCC route; do not use it as an app-level streaming API. ```swift setEntityMeshAsync( @@ -172,6 +184,7 @@ Task { ## Notes -- `.untold` is the preferred runtime format for static geometry. +- `.untold` is the runtime format for mesh loading and streaming. +- USD/USDZ assets should be converted to `.untold` before runtime use. - Animation clips exported with `--animation` can be loaded as `.untold` assets. - `setEntityStreamScene(...)` automatically aligns texture streaming distances to the manifest radii and enables the full tile/HLOD/LOD/OCC streaming pipeline. diff --git a/docs/API/UsingRegistrationSystem.md b/docs/API/UsingRegistrationSystem.md index ee2339fc..333833d4 100644 --- a/docs/API/UsingRegistrationSystem.md +++ b/docs/API/UsingRegistrationSystem.md @@ -39,6 +39,17 @@ This function: - Loads the mesh from the specified `.untold` file. - Associates the mesh with the entity. - Registers default components like RenderComponent and TransformComponent. +- Uses the immediate path; the mesh is GPU-resident when the function returns. + +For asynchronous always-resident loading, use: + +```swift +setEntityMeshAsync(entityId: entity, filename: "model", withExtension: "untold") { success in + // Mesh is registered on success. +} +``` + +For large streamed scenes, use `setEntityStreamScene(...)`. The streaming/OCC path is owned by the tile manifest pipeline, not by direct `StreamingComponent` authoring. --- diff --git a/docs/API/UsingUntoldEngineCLI.md b/docs/API/UsingUntoldEngineCLI.md index 456c94e3..959cfb40 100644 --- a/docs/API/UsingUntoldEngineCLI.md +++ b/docs/API/UsingUntoldEngineCLI.md @@ -133,9 +133,9 @@ MyGame/ └── Shaders/ ``` -The starter `GameScene.swift` shows how to load a mesh, enable geometry streaming, and enable static batching. +The starter `GameScene.swift` shows how to load `.untold` runtime assets, use `setEntityStreamScene(...)` for streamed scenes, and enable static batching. -> **Note:** The demo references `city.usdz`. Place that file in `GameData/Models/` before running. +> **Note:** Runtime examples expect `.untold` assets. Convert USD/USDZ authoring files with the exporter before placing them in `GameData/Models/`. --- diff --git a/docs/Architecture/assetProfiler.md b/docs/Architecture/assetProfiler.md index a1476105..c686b100 100644 --- a/docs/Architecture/assetProfiler.md +++ b/docs/Architecture/assetProfiler.md @@ -1,200 +1,76 @@ # Asset Profiler -## Purpose +`AssetProfiler` is a lightweight policy helper for choosing geometry and texture +residency from an `AssetProfile`. The current `.untold` streaming path no +longer profiles `MDLAsset` / `MDLMesh` data. OCC admission for tile payloads is +based on native `RuntimeAssetNode` signals gathered during `.untold` loading. -`AssetProfiler` is a lightweight classification layer that runs between `Mesh.parseAssetAsync()` and ECS entity creation. It analyzes a parsed asset's composition — geometry bytes, texture bytes, mesh count — and recommends an `AssetLoadingPolicy` that selects the most appropriate residency strategy for each memory domain independently. +--- -Its job is to answer: **given this asset and this platform's memory budget, should geometry stream or load eagerly, and should textures stream or load eagerly?** +## Current Tile OCC Admission ---- +When `setEntityStreamScene(...)` loads a tile, the tile path calls +`setEntityMeshAsync(..., streamingPolicy: .auto, blockRenderLoop: false)`. +For `.untold` assets, registration loads a `RuntimeAsset` and decides whether to +use OCC from native runtime data: -## Where It Fits +```swift +let renderableNodes = runtimeAsset.nodes.filter { !$0.primitives.isEmpty } +let estimatedGeometryBytes = renderableNodes + .flatMap(\.primitives) + .reduce(0) { $0 + $1.estimatedGPUBytes } +let budgetFraction = Float(estimatedGeometryBytes) / Float(MemoryBudgetManager.shared.geometryBudget) -``` -setEntityMeshAsync(.auto) - │ - ├─ Mesh.parseAssetAsync() ← CPU-only parse, no GPU allocation - │ └─ ProgressiveAssetData ← MDLMesh objects in CPU RAM - │ - ├─ AssetProfiler.profile() ← analyze ProgressiveAssetData - │ └─ AssetProfile ← geo bytes, tex bytes, mesh count, character - │ - ├─ AssetProfiler.classifyPolicy() ← compare profile against platform budget - │ └─ AssetLoadingPolicy ← geometryPolicy + texturePolicy - │ - └─ Routing decision - geometryPolicy == .streaming → out-of-core stubs path - geometryPolicy == .eager → immediate GPU upload path +useOCC = renderableNodes.count >= 50 || budgetFraction > 0.30 ``` -The profiler runs entirely on the CPU in the async registration task. No GPU resources are allocated during classification. +Named-node loads never use OCC. `.immediate` never uses OCC. `.outOfCore` +forces OCC, but the supported public streaming entry point is still +`setEntityStreamScene(...)`. --- ## AssetProfile +The `AssetProfile` type remains as a general residency summary: + ```swift struct AssetProfile { let totalFileBytes: Int - let estimatedGeometryBytes: Int // vertex + index bytes, summed across all MDLMesh objects - let estimatedTextureBytes: Int // estimated GPU footprint after decompression + let estimatedGeometryBytes: Int + let estimatedTextureBytes: Int let meshCount: Int let materialCount: Int let largestSingleMeshBytes: Int - let isEffectivelyMonolithic: Bool // meshCount <= 2 + let isEffectivelyMonolithic: Bool let assetCharacter: AssetCharacter } ``` -### AssetCharacter +`AssetProfiler.classifyPolicy(profile:budget:)` maps this profile to an +`AssetLoadingPolicy`: -| Value | Meaning | +| Signal | Geometry policy | |---|---| -| `.textureDominated` | Textures > 75% of combined estimate. Few or small meshes with large maps. | -| `.geometryDominated` | Geometry > 75% of combined estimate. Many or large meshes with minimal textures. | -| `.mixed` | Neither domain exceeds 75%. Both contribute meaningfully. | -| `.monolithic` | ≤ 2 meshes. Streaming still prevents OOM at registration, but the mesh loads in one step rather than incrementally. | - ---- - -## Geometry Byte Estimation - -Geometry bytes are estimated using the same formula as `CPUMeshEntry.estimatedGPUBytes` — this keeps the profiler and the budget manager consistent: - -``` -vertexBytes = vertexCount × vertexDescriptor.stride (stride default: 48 bytes) -indexBytes = vertexCount × 3 × 4 (~3 indices/vertex, 4 bytes each) -meshBytes = vertexBytes + indexBytes -``` - -Summed across all `MDLMesh` leaves in `ProgressiveAssetData.topLevelObjects`. - ---- - -## Texture Byte Estimation - -Texture bytes are estimated **without decompressing any texture data**. The profiler scans `MDLMaterial` semantic slots for texture URL references and uses file sizes as a proxy: - -**For regular file URLs** (external textures): -``` -textureBytes += fileSize × 3 // PNG/JPEG decode expansion; conservative cross-format estimate -``` - -**For USDZ-embedded textures** (bracket-notation paths like `file:///scene.usdz[0/tex.png]`): -Individual zip entries cannot be statted without decompressing. A file-level heuristic is used instead: -``` -packedTextureBytes = max(0, fileSizeBytes − (geometryBytes / 10)) -textureBytes = packedTextureBytes × 3 -``` -The geometry division by 10 approximates the compression ratio of vertex/index data in the USDZ package. This overestimates for geometry-heavy assets, which is intentional — it is always safer to over-estimate texture cost (triggers streaming) than to under-estimate it (causes OOM). +| `meshCount >= 50` | `.streaming` | +| `estimatedGeometryBytes / budget > 0.30` | `.streaming` | +| otherwise | `.eager` | -**If no texture URLs are found**, `estimatedTextureBytes` is 0 and `texturePolicy` defaults to `.eager`. - -### Scanned Material Semantics - -```swift -.baseColor, .roughness, .metallic, .bump, .emission, .opacity, .ambientOcclusion -``` +Texture policy is `.streaming` when estimated texture bytes exceed 10% of the +budget or 32 MB. --- -## AssetLoadingPolicy - -```swift -struct AssetLoadingPolicy { - var geometryPolicy: GeometryResidencyPolicy // .eager or .streaming - var texturePolicy: TextureResidencyPolicy // .eager or .streaming - var source: PolicySource // .auto or .userForced -} -``` - -The two policies are independent. A texture-dominated asset with 3 meshes and 150 MB of maps gets `geometry: .eager, texture: .streaming`. A geometry-dominated city with 400 small meshes gets `geometry: .streaming, texture: .eager`. - -### Built-in Presets - -| Preset | Geometry | Texture | -|---|---|---| -| `.fullLoad` | `.eager` | `.eager` | -| `.geometryStreaming` | `.streaming` | `.eager` | -| `.textureStreaming` | `.eager` | `.streaming` | -| `.combinedStreaming` | `.streaming` | `.streaming` | - ---- - -## Classification Logic - -### Geometry Policy - -``` -if isMonolithic: - streaming if geometryBytes / budget > 0.30 - else eager - -if meshCount >= 50: - streaming ← many meshes spike GPU allocation regardless of total size - -if geometryBytes / budget > 0.30: - streaming - -else: - eager -``` - -### Texture Policy - -``` -if textureBytes / budget > 0.10 OR textureBytes > 32 MB: - streaming - -else: - eager -``` - -### Why fractions of budget, not fixed thresholds - -The same 200 MB asset routes differently depending on the device: - -| Device | Budget | Geo fraction | Geometry policy | -|---|---|---|---| -| macOS | 1 GB | 20% | `.eager` — fits comfortably | -| iOS high-end | 512 MB | 39% | `.streaming` — too large | -| iOS low-end | 256 MB | 78% | `.streaming` — far too large | -| visionOS | 512 MB | 39% | `.streaming` — too large | - -Fixed thresholds (the old `fileSizeThresholdBytes = 50 MB`) applied the same cutoff on all platforms. A 200 MB asset would always trigger streaming, even on a macOS workstation with 1 GB of headroom. - ---- - -## Log Output - -For every `.auto` classification the profiler emits two log lines: - -``` -[AssetProfiler] 'dungeon3' (2.1 MB) → mixed | geo ~2.9 MB, tex ~6.2 MB | budget: 1024 MB | meshes: 410 -[AssetProfiler] Policy → geometry: streaming, texture: eager (source: auto) -``` - -**Line 1** — profile snapshot: filename, file size, asset character, estimated geometry bytes, estimated texture bytes, platform budget, mesh count. - -**Line 2** — the chosen policy for each domain and whether it was auto-selected or user-forced. - -If geometry streaming is selected, the out-of-core log also captures the reason: - -``` -[OutOfCore] 'dungeon3': mixed asset, geo ~2.9 MB on 1024 MB budget → out-of-core stub registration (410 stubs) -``` - ---- - -## Limitations and Known Heuristics - -**Texture estimation for USDZ-embedded textures is approximate.** The `(fileSizeBytes − geometryBytes/10) × 3` heuristic overestimates for geometry-heavy assets and underestimates for assets with highly compressed textures (e.g. ASTC at 8:1). It is biased toward overestimation intentionally. - -**Monolithic assets stream but do not incrementally load.** An asset with 1 mesh and 400 MB of geometry enters the streaming path (to prevent OOM at registration) but loads its full geometry in a single upload step. There is no incremental benefit beyond registration-time safety. +## Current Runtime Format Boundary -**External texture files must be accessible at classification time.** If textures are on a remote URL or behind a slow filesystem, the `stat()` calls in `estimateTextureBytes` will block the registration task. This is only a concern for non-local assets. +USD/USDZ are authoring/import formats for the exporter. Runtime mesh loading, +tile streaming, and OCC registration use `.untold`: -**Material semantics scanned are limited to the seven standard PBR slots.** Custom material properties outside those semantics are not counted. If an asset uses non-standard semantic names, its texture estimate will be lower than the true cost. +- `.untold` parsing uses `NativeFormatLoader` / `UntoldReader`. +- Geometry estimates come from `RuntimePrimitive.estimatedGPUBytes`. +- OCC CPU residency stores `RuntimeAssetNode` values in `CPURuntimeEntry`. +- The old ModelIO profiler assumptions for `MDLMaterial` texture URL scanning + and USDZ embedded texture heuristics no longer describe the streaming path. --- @@ -202,8 +78,8 @@ If geometry streaming is selected, the out-of-core log also captures the reason: | System | Relationship | |---|---| -| `ProgressiveAssetLoader` | Provides `ProgressiveAssetData` that `AssetProfiler.profile()` analyzes | -| `MemoryBudgetManager` | Provides `meshBudget`; all thresholds are fractions of this value | -| `GeometryStreamingSystem` | Activated when `geometryPolicy == .streaming`; manages GPU residency per entity | -| `TextureStreamingSystem` | Runs on all entities with `RenderComponent` regardless of texture policy; the policy makes the intent explicit for future per-entity gating | -| `RegistrationSystem` | Calls `AssetProfiler` in the `.auto` branch of `setEntityMeshAsync`; maps the result to `useOutOfCore` | +| `RegistrationSystem` | Performs current `.untold` OCC admission in the `.auto` tile path | +| `ProgressiveAssetLoader` | Stores `CPURuntimeEntry` data for OCC stubs | +| `MemoryBudgetManager` | Provides the geometry budget used by admission checks | +| `GeometryStreamingSystem` | Uploads and evicts OCC stubs selected by registration | +| `TextureStreamingSystem` | Manages texture residency independently of OCC admission | diff --git a/docs/Architecture/geometryStreamingSystem.md b/docs/Architecture/geometryStreamingSystem.md index 81d75511..24571c7b 100644 --- a/docs/Architecture/geometryStreamingSystem.md +++ b/docs/Architecture/geometryStreamingSystem.md @@ -69,7 +69,7 @@ Load candidates are **sorted by priority then distance** (high priority + closes Before dispatching, the scheduler applies three guards in order: 1. **Tile ownership** (`isTileOwned`) — the entity must be a descendant of a `TileComponent` entity. Non-tile-owned entities are rejected immediately and their state is never mutated. `StreamingComponent` is an internal, tile-subordinate mechanism; it is not valid on standalone entities. See [StreamingComponent Ownership Model](#streamingcomponent-ownership-model) below. -2. **CPU-entry readiness** — OOC entities whose `CPUMeshEntry` is not yet stored in `ProgressiveAssetLoader` are skipped. This prevents pre-streaming stubs from holding slots while registration is still running. +2. **CPU-entry readiness** — OOC entities whose `CPURuntimeEntry` is not yet stored in `ProgressiveAssetLoader` are skipped. This prevents pre-streaming stubs from holding slots while registration is still running. 3. **Per-candidate geometry budget check** — if the candidate's estimated GPU footprint would exceed the geometry budget, `evictLRU` is called first. When all near-band candidates share one `assetRootEntityId`, the near-band concurrency limit expands from `nearBandMaxConcurrentLoads` to `maxConcurrentLoads`. All sub-meshes of one tile asset are treated as a single burst rather than being serialized one-at-a-time. @@ -81,8 +81,9 @@ When all near-band candidates share one `assetRootEntityId`, the near-band concu 4. Spawns a Swift `Task` (runs off the main thread) Inside the async task: +- If the entity has a native OCC CPU entry → uploads from `ProgressiveAssetLoader.CPURuntimeEntry` - If the entity has a `LODComponent` → calls `reloadLODEntity()` which loads all LOD levels -- Otherwise → calls `loadMeshAsync()` which goes to `MeshResourceManager` (cache-first, file fallback) +- Otherwise → calls `loadMeshAsync()` which goes to `MeshResourceManager` for cache-backed `.untold` loading - After loading, back on the main thread via `withWorldMutationGate`: - Assigns `render.mesh` with fresh copies of uniform buffers (critical — prevents entities sharing GPU state from overwriting each other) - Sets state → `.loaded` @@ -185,7 +186,7 @@ In addition to the per-tick budget checks above, `MemoryBudgetManager` subscribe 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 asset parse 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. +**CPU heap ownership** — `evictLRU` frees GPU Metal buffers tracked by `MemoryBudgetManager`. Native OCC CPU data is owned by `ProgressiveAssetLoader` as `CPURuntimeEntry` records and is released when the tile/root is unloaded through `removeOutOfCoreAsset(rootEntityId:)`. --- @@ -229,7 +230,7 @@ The key design decisions here are: - **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 CPU heap the OS measures; rehydration context survives so cold re-stream from disk is transparent +- **Native OCC CPU registry** — OCC uploads read `CPURuntimeEntry` values from `ProgressiveAssetLoader`; those entries are released with the tile/root lifecycle --- @@ -305,7 +306,7 @@ Three sub-passes each tick, capped at `maxTileUnloadsPerUpdate` (default **2**) - **`cancelPendingEntities` before entity destruction** — when `unloadLODLevel` or `unloadHLOD` tears down child entities, it first calls `BatchingSystem.shared.cancelPendingEntities(_:)` with the render descendant IDs, purging them from all pending batching queues. This prevents "entity is missing" errors when the batching tick tries to process additions for entities that were destroyed between event queuing and tick processing. - **`notifyTileEntitiesResident` replaces the event storm** — tile/LOD/HLOD load completions call `BatchingSystem.shared.notifyTileEntitiesResident(_:)` instead of the former two-step `queueResidencyEventsForRenderDescendants` + `notifyTileParsedEntities` pairing. The single call directly registers entities in the batching system's pending additions and marks them for quiescence bypass, avoiding hundreds of individual `AssetResidencyChangedEvent` objects through `SystemEventBus`. - **Identity world transform on stubs** — tile geometry is exported in world space; no runtime coordinate conversion needed. -- **`.auto` streaming policy** — tiles use the same admission gate as regular assets; unexpectedly large tiles are gracefully rejected and retried rather than crashing. +- **`.auto` streaming policy** — tile loads use native `.untold` admission to choose immediate full-tile upload or OCC child-stub registration. - **Zombie-state guard in completion** — completion callback checks `tc.state == .parsing`. If `unloadTile` ran mid-parse (state is `.unloading`), result is discarded and the pre-created child entity is cleaned up — stub never enters a "geometry missing" zombie state. - **`defer` slot release** — `releaseActiveTileLoad` in `defer` frees the concurrency slot on all exit paths (success, failure, cancelled-state early return). - **`removeTileComponent` deregisters from streaming system** — cancels in-flight `loadTask` and calls `GeometryStreamingSystem.shared.unregisterTileEntity(entityId)` to atomically remove the entity from all four tile tracking sets (`loadedTileEntities`, `loadingTileEntities`, `activeTileLoads`, `meshEntityToTileEntity`). diff --git a/docs/Architecture/lodSystem.md b/docs/Architecture/lodSystem.md index 090943bf..1d3a196a 100644 --- a/docs/Architecture/lodSystem.md +++ b/docs/Architecture/lodSystem.md @@ -4,13 +4,13 @@ UntoldEngine has two separate LOD mechanisms that operate at different granulari | | **Entity-level LOD** (this document) | **Per-tile LOD** (tile streaming) | |---|---|---| -| Unit | Individual mesh entity (`LODComponent`) | Whole tile USDC file (`TileLODLevel`) | +| Unit | Individual mesh entity (`LODComponent`) | Whole tile `.untold` file (`TileLODLevel`) | | Control | `LODSystem` — runs every frame | `GeometryStreamingSystem.update()` — runs per tick | | Switch trigger | Camera distance vs `LODLevel.maxDistance` | Camera distance vs `TileLODLevel.switchDistance` (with hysteresis) | | Hysteresis | 5-unit inner band on finer-LOD transitions only | `lodHysteresisFactor` (default 0.90 = 10% band) on active level | | Meshes in memory | All LOD levels GPU-resident simultaneously | Only the active LOD level is loaded | | Use case | Individual detailed objects (buildings, props) | Tile-granularity intermediate representations for large scenes | -| Content pipeline | Separate OBJ/USDZ per LOD level, wired via `LODComponent` | Separate USDC per tile LOD, listed in manifest `lod_levels` array | +| Content pipeline | Separate `.untold` files per LOD level, wired via `LODComponent` | Separate `.untold` files per tile LOD, listed in manifest `lod_levels` array | | Debug tagging | `LODComponent.currentLOD` read by LOD debug renderer | `TileLODTagComponent.levelIndex` placed on render descendants | Per-tile LOD documentation: [`docs/Architecture/tilebasedstreaming.md`](tilebasedstreaming.md#per-tile-lod-levels). @@ -142,22 +142,18 @@ The system processes all 500 in sequence, but buildings where LOD hasn't changed --- -### LOD + Out-of-Core Integration +### Relationship to OCC -Prior to this integration, LOD and OOC were mutually exclusive: assets that qualified for out-of-core streaming would bypass LOD group detection, causing each `LOD0`/`LOD1`/`LOD2` object to become an independent stub entity. +The old ModelIO LOD+OOC path has been removed. There is no +`cpuLODRegistry`, `CPUMeshEntry`, or cold rehydration path in the current +native streaming architecture. -**How it works now:** +Current behavior is split by level: -`setEntityMeshAsync` runs LOD group detection *before* the OOC branching decision. When the asset both qualifies for OOC streaming *and* contains LOD groups, the **LOD+OOC path** runs: - -1. **Registration** — one entity per LOD group (not one per MDLObject). Each entity gets a `LODComponent` with stub `LODLevel`s (empty mesh, `.notResident`) and a `StreamingComponent(.unloaded)`. - -2. **CPU registry** — `ProgressiveAssetLoader.cpuLODRegistry[groupEntityId][lodIndex]` stores a `CPUMeshEntry` for every LOD level. The MDLAsset is retained so CPU buffers remain valid. - -3. **GPU upload** — when `GeometryStreamingSystem` picks up the entity (`.unloaded` → in streaming range), it calls `uploadActiveLODFromCPU`. This uploads **all** LOD levels in one pass from the CPU registry, marks each `LODLevel.residencyState = .resident`, and sets `renderComponent.mesh` to the level appropriate for the current camera distance. - -4. **LOD switching after load** — `LODSystem.applyLOD` continues to work as normal: it reads from `lodComponent.lodLevels[n].mesh` (now populated) and swaps `renderComponent.mesh`. No additional streaming requests are needed — all levels are already GPU-resident. - -5. **Cold re-hydration** — if `releaseWarmAsset` was called on the root and a group entity re-enters streaming range, `rehydrateColdAsset` re-parses the USDZ, re-runs LOD detection, and rebuilds `cpuLODRegistry` entries before `uploadActiveLODFromCPU` runs. - -**Result:** the caller sets any `MeshStreamingPolicy` and LOD assets always get proper `LODComponent` wiring. The mutual exclusivity that required users to choose between OOC and LOD is eliminated. +- Entity-level LOD is an always-resident object workflow. LOD levels are loaded + as `.untold` mesh assets and swapped by `LODSystem`. +- Tile-level LOD is part of `setEntityStreamScene(...)`. The manifest lists + `.untold` LOD payloads, and `GeometryStreamingSystem` loads/unloads the active + tile LOD representation by distance. +- OCC applies inside large full-tile `.untold` payloads. It creates child + `StreamingComponent` stubs backed by `ProgressiveAssetLoader.CPURuntimeEntry`. diff --git a/docs/Architecture/meshResourceManager.md b/docs/Architecture/meshResourceManager.md index fd1ec7c6..546791e2 100644 --- a/docs/Architecture/meshResourceManager.md +++ b/docs/Architecture/meshResourceManager.md @@ -1,170 +1,115 @@ # MeshResourceManager -`MeshResourceManager` is a singleton that acts as the shared GPU memory layer for mesh assets. It caches entire USDZ files, tracks which entities use which meshes via reference counting, and evicts unused geometry under memory pressure. +`MeshResourceManager` is the shared GPU mesh cache for immediate and +cache-backed `.untold` loads. It caches all renderable mesh arrays produced from +one `.untold` file, tracks which entities retain each named mesh, and evicts +unused cached geometry under memory pressure. ---- +It is not the OCC CPU registry. Tile-owned OCC stubs use +`ProgressiveAssetLoader` and `CPURuntimeEntry`. -## The Core Data Model +--- -`MeshResourceManager` is a **singleton** (`shared`) that manages two key dictionaries: +## Core Data Model ``` -resources: URL -> MeshResource (the cache — one entry per USDZ file) -entityToMesh: EntityID -> (URL, name) (which entity uses which mesh) +resources: URL -> MeshResource (one cache entry per .untold file) +entityToMesh: EntityID -> (URL, name) (which cached mesh an entity retains) ``` -A `MeshResource` holds **all meshes** from a single USDZ file, keyed by asset name: +A `MeshResource` stores all renderable mesh groups from a `.untold` file: -``` -city_block_A.usdz -> MeshResource { - meshesByName: { "building_01": [...], "window_01": [...], "door_01": [...] } - refCountByName: { "building_01": 12, "window_01": 48, "door_01": 12 } - totalMemorySize: 42_000_000 // bytes on GPU - lastAccessFrame: 1042 +```swift +struct MeshResource { + var meshesByName: [String: [Mesh]] + var refCountByName: [String: Int] + var totalMemorySize: Int + var sourceURL: URL + var lastAccessFrame: Int } ``` +The cache key is the source URL. The per-mesh key is the runtime asset name. + --- -## Phase 1 — Loading (First Request) +## Loading -Say entity `e001` needs `"building_01"` from `city_block_A.usdz`: +When a caller requests a mesh: +```swift +let meshes = await MeshResourceManager.shared.loadMesh(url: url, meshName: "building_01") ``` -loadMesh(url: city_block_A.usdz, meshName: "building_01") -``` - -**Step 1 — Cache check** (`getCachedMesh`): nothing cached yet → miss. -**Step 2 — Single-flight gate** (`waitForExistingLoadOrBecomeLoader`): -- First caller wins: it is designated the **loader** and gets `true`. -- If 10 other entities request the same file simultaneously, they all queue up as **waiters** inside `inFlightLoadWaiters[url]`. They suspend via `CheckedContinuation` — no busy-waiting. +The manager: -**Step 3 — Parse & upload** (`Mesh.loadSceneMeshesAsync`): -- The entire USDZ is parsed once. Every mesh in the file is uploaded to GPU buffers. -- Result is a `[[Mesh]]` — one inner array per named asset. +1. Checks `resources[url]` for a cached mesh group. +2. Uses a single-flight gate so concurrent requests for the same URL share one load. +3. Loads the `.untold` file through `NativeFormatLoader`. +4. Converts every renderable `RuntimeAssetNode` to `[Mesh]`. +5. Stores the result in `meshesByName`. -**Step 4 — Build the dictionary and cache it**: -```swift -meshesByName["building_01"] = [mesh1, mesh2, ...] // LODs or submeshes -meshesByName["window_01"] = [mesh3, ...] -meshesByName["door_01"] = [mesh4, ...] -totalMemorySize = 42 MB -``` -This is stored in `resources[city_block_A.usdz]`. - -**Step 5 — Wake waiters** (`finishInFlightLoad`): -- All 10 suspended callers are resumed with `false` (they don't load — file is already cached). -- Each then calls `getCachedMesh` and gets their mesh instantly. +Only `.untold` files are supported by this runtime cache. --- -## Phase 2 — Reference Counting (Entity Lifecycle) +## Reference Counting -When the streaming system decides entity `e001` will **render** `"building_01"`: +When an entity begins using a cached mesh: ```swift -retain(url: city_block_A.usdz, meshName: "building_01", for: e001) -``` - -This: -1. Releases any previous mesh the entity held (safety cleanup). -2. Records `entityToMesh[e001] = (city_block_A.usdz, "building_01")`. -3. Increments `refCountByName["building_01"]` from 0 → 1. - -After 500 entities are assigned across the city block: - -``` -refCountByName: { "building_01": 45, "window_01": 180, "streetlight": 60, ... } -totalRefCount = 500 -isInUse = true ← cannot be evicted +MeshResourceManager.shared.retain(url: url, meshName: "building_01", for: entityId) ``` -When an entity scrolls **out of view** and is culled: +This records the entity-to-mesh mapping and increments the mesh name's reference +count. When the entity unloads or is destroyed: ```swift -release(entityId: e099) +MeshResourceManager.shared.release(entityId: entityId) ``` -This removes `entityToMesh[e099]` and decrements `refCountByName["building_01"]` from 45 → 44. +The mapping is removed and the reference count is decremented. Cache entries +with live references are not eligible for eviction. --- -## Phase 3 — Eviction (Memory Pressure) +## Cache Prewarming -Three eviction strategies exist: +Immediate registration paths can call: -| Method | When to Use | -|---|---| -| `evict(url:)` | Force-remove one specific file (only if `refCount == 0`) | -| `evictUnused()` | Sweep all files with zero references | -| `evictToFreeMemory(targetBytes:)` | **LRU** — evict oldest-accessed files first until `targetBytes` freed | - -For a city block, `evictToFreeMemory` is most useful. Say GPU budget is exceeded by 80 MB: - -``` -candidates = resources where totalRefCount == 0 - sorted by lastAccessFrame ascending (oldest first) - -Evict city_block_C.usdz → frees 38 MB (last seen frame 200) -Evict city_block_D.usdz → frees 44 MB (last seen frame 310) -Total freed: 82 MB ✓ +```swift +MeshResourceManager.shared.cacheLoadedMeshes(url: url, meshArrays: meshArrays) ``` -For each evicted mesh, `mesh.cleanUp()` is called to free the Metal GPU buffers. - -`lastAccessFrame` is updated every time a mesh is accessed (cache hit) or retained, so actively-used files naturally survive LRU pressure. +This seeds the cache with meshes that were already loaded during registration, +so later cache-backed requests do not reparse the file. --- -## Thread Safety - -All state is protected by a **concurrent `DispatchQueue`** with the readers-writers pattern: -- Reads use `accessQueue.sync` — concurrent reads are fine. -- Writes use `accessQueue.sync(flags: .barrier)` — exclusive access, blocks concurrent reads. +## Eviction -This makes the manager safe to call from multiple streaming tasks loading different USDZ files in parallel. +Three cleanup paths are available: ---- - -## Summary Flow for 500-Mesh City Block - -``` -Frame 0: Scene starts loading - → 5 USDZ files queued - → Each file: one task becomes loader, others wait - → All 5 files parsed, all meshes cached in GPU memory - -Frame 1–N: Entities stream in/out of view - → retain() as entities enter view frustum - → release() as entities leave - → refCounts track exactly how many entities use each mesh - -Memory pressure detected: - → evictToFreeMemory() walks LRU list - → Only files with refCount==0 are eligible - → GPU buffers freed via cleanUp() - → Files still in view are untouched -``` +| Method | Purpose | +|---|---| +| `evict(url:)` | Force-remove one URL if it has no live refs | +| `evictUnused()` | Sweep all zero-ref cache entries | +| `evictToFreeMemory(targetBytes:)` | LRU eviction of zero-ref entries until the target is reached | -The key insight: **one disk parse per USDZ file**, no matter how many entities share its meshes. Reference counting prevents premature eviction, and LRU ensures the least-recently-seen geometry is freed first under memory pressure. +Eviction calls `mesh.cleanUp()` on cached meshes to release Metal resources. --- -## Callers +## Relationship to Streaming -`MeshResourceManager` is used by three systems: +`GeometryStreamingSystem` uses two residency layers: -### GeometryStreamingSystem — Primary driver -Owns the full lifecycle: -- Updates `currentFrame` each tick to keep LRU timestamps fresh. -- Calls `loadMesh` + `retain` when an entity streams into view. -- Calls `release` when an entity streams out of view. -- Calls `evictUnused()` to free GPU memory. -- Reads `getStats()` for diagnostics and memory budgeting. - -### RegistrationSystem — Cache pre-warmer -Calls `cacheLoadedMeshes(url:meshArrays:)` when entities are registered into the scene, so meshes are already in the cache before the streaming system requests them. Also calls `release` when entities are unregistered. +| Path | CPU/GPU source | +|---|---| +| Immediate/full-load `.untold` geometry | `MeshResourceManager` cache and entity-local mesh copies | +| Tile-owned OCC stubs | `ProgressiveAssetLoader.CPURuntimeEntry` uploaded on demand | -### UntoldEngine (Renderer) — Read-only monitoring -Reads `getStats()` only, likely for a debug overlay or performance HUD. +Full-load tile geometry is not tracked in `loadedStreamingEntities`, so ordinary +OCC LRU eviction does not free it. Tile unload or +`GeometryStreamingSystem.forceUnloadAllParsedTiles()` is responsible for +tearing down full-load tile geometry. diff --git a/docs/Architecture/outOfCore.md b/docs/Architecture/outOfCore.md index 0c6fa443..6a494269 100644 --- a/docs/Architecture/outOfCore.md +++ b/docs/Architecture/outOfCore.md @@ -10,17 +10,17 @@ The public entry point for streamed geometry is: setEntityStreamScene(entityId: sceneRoot, manifest: "city", withExtension: "json") { _ in } ``` -Inside that pipeline, large tiles may be classified as **OOC** during `setEntityMeshAsync(streamingPolicy: .auto)`. +Inside that pipeline, large tiles may be classified as **OCC/OOC** during the internal `setEntityMeshAsync(streamingPolicy: .auto)` tile parse. ## The Three Runtime Roles | System | Responsibility | |---|---| -| `RegistrationSystem` | Parses the tile payload and chooses `fullLoad` vs OOC registration | -| `ProgressiveAssetLoader` | Stores CPU-resident `CPUMeshEntry` records and warm/cold rehydration context | +| `RegistrationSystem` | Parses the `.untold` tile payload and chooses immediate vs OCC registration | +| `ProgressiveAssetLoader` | Stores CPU-resident `CPURuntimeEntry` records keyed by OCC stub entity | | `GeometryStreamingSystem` | Uploads and evicts tile-owned OCC stubs by distance and budget | -`MeshResourceManager` still serves disk/cache-backed mesh loads for non-OOC paths, but OOC residency is fundamentally driven by `ProgressiveAssetLoader`. +`MeshResourceManager` serves cache-backed `.untold` loads for non-OCC paths. OCC residency is driven by `ProgressiveAssetLoader`. ## What OOC Means Now @@ -28,7 +28,7 @@ When a large tile is routed to the OOC path: 1. The tile payload is parsed on the CPU. 2. Child stub entities are created with `StreamingComponent(state: .unloaded)`. -3. Their CPU mesh data is stored in `ProgressiveAssetLoader`. +3. Their `RuntimeAssetNode` CPU data is stored in `ProgressiveAssetLoader`. 4. `GeometryStreamingSystem` uploads those stubs incrementally as the camera approaches. 5. Evicted stubs can re-upload from the warm CPU registry without reparsing the file. @@ -38,8 +38,10 @@ Those `StreamingComponent` stubs are valid only when they are descendants of a ` The runtime still exposes `MeshStreamingPolicy`, but the architecture has moved: -- `setEntityMeshAsync(..., streamingPolicy: .auto)` is fine for normal always-resident assets +- `setEntityMesh(...)` is synchronous and always immediate +- `setEntityMeshAsync(...)` defaults to `.immediate` and is for always-resident assets - `setEntityMeshAsync(..., streamingPolicy: .immediate)` forces full upload +- `setEntityMeshAsync(..., streamingPolicy: .auto)` is used by tile loading so the runtime can choose immediate vs OCC - `setEntityStreamScene(...)` is the supported public streaming path - `StreamingComponent` and `enableStreaming(...)` are internal tile/OOC mechanisms @@ -75,19 +77,20 @@ The asset admission/classification stage decides whether this tile becomes: ### 2. Stub registration -For an OOC tile, `registerProgressiveStubEntity(...)` creates one ECS stub per mesh leaf: +For an OOC tile, `registerUntoldProgressiveStubEntity(...)` creates one ECS stub per renderable `RuntimeAssetNode`: - transform and bounds are registered immediately - `StreamingComponent` starts as `.unloaded` - the stub is inserted into the octree - no GPU buffers are created yet +- renderable nodes are always child entities, including single-node assets, so descendant tracking is consistent ### 3. CPU registry -Each stub stores a `CPUMeshEntry` in `ProgressiveAssetLoader`, including: +Each stub stores a `CPURuntimeEntry` in `ProgressiveAssetLoader`, including: -- source asset node reference -- vertex descriptor +- source `RuntimeAssetNode` +- source `.untold` URL - unique mesh name - estimated GPU bytes - original loading policy @@ -114,16 +117,6 @@ When the camera moves away or geometry pressure rises: This is why a normal OOC re-approach can re-upload without a disk read. -### 6. Warm-to-cold transition - -On critical pressure, the engine may release warm CPU state: - -```swift -ProgressiveAssetLoader.shared.releaseWarmAsset(rootEntityId:) -``` - -This frees the retained asset parse tree and CPU mesh buffers but preserves the rehydration context. A later re-approach reparses the root asset once and rebuilds the CPU registry. - ## Interaction with Tile Streaming OOC is just one representation inside the broader tile system: @@ -138,6 +131,6 @@ That is why the runtime documentation should describe OOC as an implementation d ## Key Takeaways - OOC still exists, but it is now subordinate to tile ownership. -- `ProgressiveAssetLoader` is the CPU residency layer. +- `ProgressiveAssetLoader` is the `.untold` CPU runtime-node residency layer. - `GeometryStreamingSystem` is the GPU residency scheduler. - `setEntityStreamScene(...)` is the supported public streaming API surface. diff --git a/docs/Architecture/progressiveAssetLoader.md b/docs/Architecture/progressiveAssetLoader.md index 36571ed0..e4322410 100644 --- a/docs/Architecture/progressiveAssetLoader.md +++ b/docs/Architecture/progressiveAssetLoader.md @@ -2,230 +2,148 @@ ## TL;DR -`ProgressiveAssetLoader` is a **CPU registry** — its sole responsibility is storing `CPUMeshEntry` records for out-of-core stub entities and serving them to `GeometryStreamingSystem` on demand. +`ProgressiveAssetLoader` is the CPU registry for native `.untold` out-of-core +geometry. It stores `CPURuntimeEntry` records for tile-owned OCC stub entities +and serves those records to `GeometryStreamingSystem` when a stub enters +streaming range. -> **Note:** This document describes the current architecture. The earlier tick-based progressive loader (per-frame job queue, `PendingObjectItem`, `enqueue(job)`, `tick()` processing N meshes per frame) was replaced by the out-of-core stub system. `tick()` is retained as a no-op for call-site compatibility only. +The previous USDZ/ModelIO path (`MDLAsset`, `MDLObject`, `MDLMesh`, +`CPUMeshEntry`, LOD+OOC registries, texture prewarm locks, and cold +rehydration) has been removed from the streaming/OCC system. Runtime OCC now +operates on `.untold` `RuntimeAssetNode` data only. + +`tick()` remains as a no-op compatibility shim. --- ## What It Stores -When `setEntityMeshAsync` routes an asset through the out-of-core path it stores CPU-side geometry in one of two registries depending on whether the asset contains LOD groups: - -- **`cpuMeshRegistry`** (`[EntityID: CPUMeshEntry]`) — one entry per stub entity (regular OOC assets) -- **`cpuLODRegistry`** (`[EntityID: [Int: CPUMeshEntry]]`) — one entry per LOD level per LOD group entity (LOD+OOC assets) - -Both registries store `CPUMeshEntry` records: +When a `.untold` tile is routed through the OCC path, registration creates +zero-GPU child stubs for each renderable runtime node. Each stub gets one CPU +entry: ```swift -struct CPUMeshEntry { - let object: MDLObject // MDLMesh with CPU-heap vertex/index data - let vertexDescriptor: MDLVertexDescriptor - let textureLoader: TextureLoader - let device: MTLDevice +struct CPURuntimeEntry { + let node: RuntimeAssetNode let url: URL - let filename: String - let withExtension: String - let uniqueAssetName: String // "Hull_A#42" — stable across load cycles - let estimatedGPUBytes: Int // vertex + index bytes; used for pre-emptive budget reservation + let uniqueAssetName: String + let estimatedGPUBytes: Int + let residencyPolicy: AssetLoadingPolicy } ``` -Entries are keyed by child entity ID. `GeometryStreamingSystem` retrieves them via `retrieveCPUMesh(for:)` when an entity enters streaming range, copies the MDL buffers into Metal-backed buffers, and registers a `RenderComponent`. The CPU entry is **never removed on unload** — re-approaching an evicted entity re-uploads from RAM with no disk I/O. - -### LOD CPU Registry (LOD+OOC Path) - -When a USDZ asset contains LOD groups (top-level objects named `Tree_LOD0`, `Tree_LOD1`, etc.) and qualifies for OOC streaming, `setEntityMeshAsync` takes the **LOD+OOC path** instead of the per-stub path: - -- One entity is created per LOD group (instead of one entity per MDLObject) -- Each entity gets a `LODComponent` with stub `LODLevel`s — empty mesh arrays, `residencyState: .notResident` -- One `CPUMeshEntry` is stored in `cpuLODRegistry[groupEntityId][lodIndex]` for each LOD level +Entries live in: ```swift -// LOD+OOC: per-level CPU entries, keyed by (group entity ID, LOD index) -cpuLODRegistry[treeEntityId] = [ - 0: CPUMeshEntry(object: tree_LOD0_MDLObject, uniqueAssetName: "Tree_LOD0", ...), - 1: CPUMeshEntry(object: tree_LOD1_MDLObject, uniqueAssetName: "Tree_LOD1", ...), - 2: CPUMeshEntry(object: tree_LOD2_MDLObject, uniqueAssetName: "Tree_LOD2", ...), -] +private var cpuRuntimeRegistry: [EntityID: CPURuntimeEntry] +private var rootEntityChildren: [EntityID: [EntityID]] ``` -`GeometryStreamingSystem` detects the LOD+OOC path via `hasCPULODData(for:)` and calls `uploadActiveLODFromCPU` instead of `uploadFromCPUEntry`. This uploads **all** LOD levels from the CPU registry in one pass, then sets the render component to the level appropriate for the current camera distance. Subsequent LOD switches are handled by `LODSystem` which swaps `renderComponent.mesh` from the already-resident `lodComponent.lodLevels` array — no additional streaming needed until the entity is evicted and re-enters range. - ---- - -## The MDLAsset Lifetime Problem +The key is the OCC stub entity ID. `rootEntityChildren` groups those stub IDs +under the tile mesh-root entity so teardown can remove all CPU entries for a +tile at once. -`MDLMeshBufferDataAllocator` (used by `parseAssetAsync`) backs all CPU buffers via the `MDLAsset` container. If the asset is released, all child MDLMesh CPU pointers become dangling. - -`ProgressiveAssetLoader` solves this with `rootAssetRefs`: - -```swift -private var rootAssetRefs: [EntityID: MDLAsset] = [:] -``` - -`storeAsset(_:for:)` pins the `MDLAsset` to the root entity ID. It stays alive until `removeOutOfCoreAsset(rootEntityId:)` is called at entity destruction time. +`RuntimeAssetNode` owns self-contained vertex and index `Data` blobs, so no +parent `MDLAsset` or file container has to remain alive for buffer validity. --- -## Background Texture Prewarm +## Registration Flow -`storeAsset(_:for:)` immediately fires a background `Task` at `.userInitiated` priority to call `loadTextures()` before any mesh enters streaming range: - -```swift -func storeAsset(_ asset: MDLAsset, for rootEntityId: EntityID) { - // Pin the asset and create the per-asset texture lock. - lock.lock() - rootAssetRefs[rootEntityId] = asset - assetTextureLocks[rootEntityId] = NSLock() - lock.unlock() - // Kick off background prewarm immediately. - prewarmTexturesAsync(for: rootEntityId) -} ``` - -The prewarm task acquires the per-asset texture lock, calls `ensureTexturesLoaded`, and releases the lock — all off the critical path. By the time the first mesh enters streaming range, `loadTextures()` has typically already completed, so the first-upload path sees a no-op `ensureTexturesLoaded` call and zero lock wait. - -`activePrewarmRoots` tracks which roots have an in-flight prewarm task. `GeometryStreamingSystem` queries `isPrewarmActive(for:)` in the dispatch loop and defers uploading entities for that root until the prewarm completes. This prevents the first batch of uploads from blocking on the texture lock for the full remaining prewarm duration. - ---- - -## Per-Asset Texture Serialization - -`MDLAsset` is not thread-safe. Two `GeometryStreamingSystem` tasks uploading different meshes from the same asset concurrently can race during `loadTextures()`. `ProgressiveAssetLoader` prevents this with a per-asset `NSLock`: - -```swift -private var assetTextureLocks: [EntityID: NSLock] = [:] +setEntityStreamScene(...) + └─ GeometryStreamingSystem.loadTile(...) + └─ setEntityMeshAsync(..., streamingPolicy: .auto, blockRenderLoop: false) + ├─ NativeFormatLoader.loadAssetSync(...) → RuntimeAsset + ├─ classify tile as immediate or OCC + └─ registerUntoldRuntimeAssetOCC(...) + ├─ create child OCC stubs + ├─ attach StreamingComponent(.unloaded) + ├─ register each stub in the octree + ├─ storeCPURuntimeEntry(entry, for: childStubId) + └─ registerChildren(childStubIds, for: tileMeshRootId) ``` -`storeAsset` creates the lock alongside the asset reference. Every upload task brackets only `ensureTexturesLoaded` with the lock — the lock is released **before** `makeMeshesFromCPUBuffers`: +Renderable nodes are always represented by child stub entities, including +single-node assets. This keeps descendant counting and tile ownership checks +consistent. + +For parented nodes, registration computes: ```swift -ProgressiveAssetLoader.shared.acquireAssetTextureLock(for: rootId) -ProgressiveAssetLoader.shared.ensureTexturesLoaded(for: rootId) -ProgressiveAssetLoader.shared.releaseAssetTextureLock(for: rootId) -// makeMeshesFromCPUBuffers runs without the lock — MDLAsset is read-only after loadTextures() +childLocal = inverse(parentWorld) * nodeWorld ``` -After `loadTextures()` completes the `MDLAsset` is in a stable read-only state. Concurrent `makeMeshesFromCPUBuffers` calls from the same asset are safe without the lock, so all three upload slots can proceed in parallel once the prewarm is done. - -Only the `ensureTexturesLoaded` call is serialized per asset. Meshes from *different* assets upload concurrently without any contention. +After `setParent`, the stub lands at the node's intended world transform +without double-applying the parent transform. --- -## Deferred `loadTextures()` +## Upload Flow -Large assets skip `asset.loadTextures()` at parse time to avoid the OOM risk of decompressing all textures before the app is interactive. The call is deferred via `ensureTexturesLoaded`: +When a stub enters range, `GeometryStreamingSystem` retrieves its CPU entry: ```swift -func ensureTexturesLoaded(for rootEntityId: EntityID) { - // Must be called while per-asset texture lock is held. - // Calls asset.loadTextures() exactly once per asset lifetime. -} +let entry = ProgressiveAssetLoader.shared.retrieveCPURuntimeEntry(for: entityId) ``` -`assetTexturesLoaded: Set` ensures the call happens exactly once. In normal operation the prewarm task wins the race and marks the asset loaded before any upload task reaches `ensureTexturesLoaded`, making the upload-path call a no-op. +The upload path converts `entry.node.primitives` into engine `Mesh` values, +creates Metal buffers, registers a `RenderComponent`, and marks the +`StreamingComponent` as `.loaded`. + +Normal unload clears GPU residency but keeps the `CPURuntimeEntry` warm, so +re-approaching the same stub re-uploads from CPU memory without reparsing the +`.untold` file. --- ## API Surface | Method | Purpose | -|--------|---------| -| `storeCPUMesh(_:for:)` | Store a `CPUMeshEntry` keyed by child entity ID (regular OOC) | -| `retrieveCPUMesh(for:)` | Fetch the entry for `GeometryStreamingSystem` upload (regular OOC) | -| `removeCPUMesh(for:)` | Remove a single entry (rarely needed; prefer `removeOutOfCoreAsset`) | -| `storeCPULODMesh(_:for:lodIndex:)` | Store a `CPUMeshEntry` for one LOD level of a LOD group entity | -| `retrieveCPULODMesh(for:lodIndex:)` | Fetch the entry for a specific LOD level | -| `retrieveAllCPULODMeshes(for:)` | Fetch all LOD-level entries for a group entity | -| `hasCPULODData(for:)` | Returns `true` if the entity was registered via the LOD+OOC path | -| `removeCPULODEntry(for:)` | Remove all LOD entries for a group entity | -| `storeAsset(_:for:)` | Pin an `MDLAsset`, create its per-asset texture lock, and kick off background prewarm | -| `isPrewarmActive(for:)` | Returns `true` while the background prewarm task holds the texture lock for this root | -| `registerChildren(_:for:)` | Associate child entity IDs with a root for bulk cleanup | -| `acquireAssetTextureLock(for:)` | Lock before calling `ensureTexturesLoaded` | -| `releaseAssetTextureLock(for:)` | Unlock immediately after `ensureTexturesLoaded` — before GPU upload work | -| `ensureTexturesLoaded(for:)` | Call `loadTextures()` exactly once per asset (must hold texture lock) | -| `removeOutOfCoreAsset(rootEntityId:)` | Release all CPU entries (both registries) + MDLAsset for a destroyed root entity | -| `cancelAll()` | Release everything — use on scene reset or test teardown | -| `tick()` | No-op stub; retained for call-site compatibility | +|---|---| +| `storeCPURuntimeEntry(_:for:)` | Store one `.untold` runtime node entry for an OCC stub | +| `retrieveCPURuntimeEntry(for:)` | Fetch the entry for GPU upload | +| `hasCPURuntimeData(for:)` | Check whether a stub has CPU data ready | +| `removeCPURuntimeEntry(for:)` | Remove one stub entry | +| `registerChildren(_:for:)` | Associate child stub IDs with a tile/root entity | +| `getChildren(for:)` | Return registered OCC children for a root | +| `removeOutOfCoreAsset(rootEntityId:)` | Release all CPU entries for a root | +| `cancelAll()` | Release all CPU entries; used for scene reset and tests | +| `tick()` | No-op compatibility shim | + +There is no longer a public or internal `storeAsset`, `releaseWarmAsset`, +`cpuLODRegistry`, `rootAssetRefs`, or per-asset texture lock in this system. --- -## Data Flow +## Memory Model ``` -setEntityMeshAsync (out-of-core path — regular OOC) - │ - ├─ parseAssetAsync() → MDLAsset in CPU RAM (no GPU spike) - ├─ registerProgressiveStubEntity() → N ECS stubs, StreamingComponent(.unloaded) - ├─ storeCPUMesh(entry, for: childId) × N → cpuMeshRegistry - ├─ storeAsset(asset, for: rootId) → rootAssetRefs, assetTextureLocks - │ └─ prewarmTexturesAsync() → background Task: acquireLock / loadTextures() / releaseLock - ├─ registerChildren(childIds, for: rootId) - └─ completion(true) → GeometryStreamingSystem picks up stubs automatically (always running) - -setEntityMeshAsync (out-of-core path — LOD+OOC) - │ - ├─ parseAssetAsync() → MDLAsset in CPU RAM - ├─ detectImportedLODGroups() → N LOD groups detected - ├─ (per group) createEntity + LODComponent(stubs) + StreamingComponent(.unloaded) - ├─ storeCPULODMesh(entry, for: groupId, lodIndex:) × (N groups × L levels) → cpuLODRegistry - ├─ storeAsset(asset, for: rootId) - │ └─ prewarmTexturesAsync() → background Task: acquireLock / loadTextures() / releaseLock - ├─ registerChildren(groupEntityIds, for: rootId) - └─ completion(true) - -GeometryStreamingSystem (adaptive tick: 16 ms during backlog, 100 ms steady-state) - │ - ├─ isPrewarmActive(rootId)? → YES → defer all entities for this root (slots stay free) - │ - ├─ entity within streamingRadius && state == .unloaded - │ ├─ hasCPULODData? → YES → uploadActiveLODFromCPU() - │ │ ├─ retrieveAllCPULODMeshes(for: entityId) - │ │ ├─ acquireAssetTextureLock / ensureTexturesLoaded / releaseAssetTextureLock - │ │ ├─ makeMeshesFromCPUBuffers() × L levels ← lock released; parallel uploads safe - │ │ ├─ LODComponent.lodLevels[i].residencyState = .resident for each uploaded level - │ │ └─ registerRenderComponent() at distance-appropriate LOD - │ │ - │ └─ hasCPULODData? → NO → retrieveCPUMesh / uploadFromCPUEntry (regular OOC) - │ ├─ acquireAssetTextureLock / ensureTexturesLoaded / releaseAssetTextureLock - │ ├─ makeMeshesFromCPUBuffers() ← lock released; parallel uploads safe - │ └─ registerRenderComponent() - │ - └─ entity beyond unloadRadius && state == .loaded - └─ render.mesh = [] (cpu entries kept — re-upload from RAM on re-approach) - -destroyAllEntities / scene reset - └─ removeOutOfCoreAsset(rootEntityId:) → frees both CPU registries + MDLAsset +CPU RAM: RuntimeAssetNode Data blobs for unloaded/loaded OCC stubs +GPU RAM: Only OCC stubs currently within streaming range +Disk: Read when the tile is parsed ``` ---- - -## Memory Model at Steady State - -``` -CPU RAM: all leaf meshes' MDLMesh vertex/index data — always resident -GPU RAM: only entities within streamingRadius — uploaded on demand -Disk: read exactly once at parse time -``` - -This trades a modest CPU-RAM footprint for predictable GPU memory usage and zero-latency re-uploads after eviction. +This trades CPU memory for predictable GPU residency. The CPU copy is retained +until the tile/root is destroyed or `removeOutOfCoreAsset(rootEntityId:)` is +called. --- ## Cleanup -Call `removeOutOfCoreAsset(rootEntityId:)` when destroying a root entity to free its CPU-heap geometry and texture-lock state: +Tile unload calls: ```swift ProgressiveAssetLoader.shared.removeOutOfCoreAsset(rootEntityId: rootId) ``` -`destroyAllEntities` does not call this automatically — you must call it explicitly if you are managing entity lifetimes outside the engine's destruction path. +This removes all `CPURuntimeEntry` records registered for that root. -For full teardown (scene resets, tests): +For full teardown: ```swift ProgressiveAssetLoader.shared.cancelAll() ``` + +Use this during scene resets and test teardown. diff --git a/docs/Architecture/streamingCacheLifecycle.md b/docs/Architecture/streamingCacheLifecycle.md index 96bf4f23..cfcb08f0 100644 --- a/docs/Architecture/streamingCacheLifecycle.md +++ b/docs/Architecture/streamingCacheLifecycle.md @@ -5,8 +5,8 @@ This document describes how the current streaming architecture manages geometry | Layer | Owns | |---|---| | `GeometryStreamingSystem` | Distance-based residency decisions | -| `ProgressiveAssetLoader` | Warm/cold CPU mesh state for tile-owned OOC assets | -| `MeshResourceManager` | Shared GPU mesh cache for non-OOC and disk-backed reload paths | +| `ProgressiveAssetLoader` | Warm CPU `RuntimeAssetNode` state for tile-owned OCC assets | +| `MeshResourceManager` | Shared GPU mesh cache for immediate/cache-backed `.untold` paths | ## Two Residency Modes @@ -23,10 +23,10 @@ For eager loads, `MeshResourceManager` owns the shared mesh data: For large streamed tiles, `ProgressiveAssetLoader` owns the CPU source data: -- `CPUMeshEntry` stores the parsed CPU mesh buffers +- `CPURuntimeEntry` stores the parsed `RuntimeAssetNode` - `GeometryStreamingSystem` uploads those buffers on demand - eviction normally drops only GPU residency -- critical-pressure cooling may release the CPU copy too +- tile teardown releases the CPU entries for that root ## Current Lifecycle @@ -41,7 +41,7 @@ When a tile enters prefetch range, `loadTile(entityId:)` parses the tile payload At that point the runtime chooses one of two outcomes: - **full-load tile**: render entities are GPU-resident immediately -- **OOC tile**: child `StreamingComponent` stubs are registered and backed by `CPUMeshEntry` +- **OCC tile**: child `StreamingComponent` stubs are registered and backed by `CPURuntimeEntry` ### 3. OOC upload @@ -49,9 +49,8 @@ For OOC stubs, `GeometryStreamingSystem.loadMesh(...)`: 1. checks tile ownership 2. reserves an active streaming slot -3. uploads from `ProgressiveAssetLoader` when warm -4. falls back to cold rehydration if the root was cooled -5. marks the entity loaded and emits residency change events +3. uploads from `ProgressiveAssetLoader` +4. marks the entity loaded and emits residency change events ### 4. Full-load cache use @@ -70,22 +69,12 @@ When geometry leaves range or memory pressure rises: - `unloadMesh(...)` clears the entity's live mesh reference - `MeshResourceManager.release(entityId:)` decrements shared cache refs when applicable -- OOC stubs keep their CPU source warm unless explicitly cooled +- OCC stubs keep their CPU source warm until tile/root teardown ### 6. Cache cleanup `MeshResourceManager.evictUnused()` removes zero-ref cached meshes. This is the first stage of geometry relief before more aggressive runtime eviction. -### 7. CPU cooling - -Under critical memory pressure the engine may call: - -```swift -ProgressiveAssetLoader.shared.releaseWarmAsset(rootEntityId:) -``` - -That frees the retained asset parse tree and child CPU buffers for that streamed root while preserving enough context to reparse later. - ## Why the Split Exists The split cache model supports both fast reuse and bounded memory: @@ -100,6 +89,6 @@ When reviewing a streamed tile today, think of residency in this order: 1. Is the tile stub in range? 2. Did the tile classify as full-load or OOC? -3. If OOC, is the CPU source warm or cold? +3. If OOC, does the child stub have a `CPURuntimeEntry`? 4. Is the GPU copy currently resident? 5. Is batching representing the entity directly or via a cell artifact? diff --git a/docs/Architecture/streamingRegionManager.md b/docs/Architecture/streamingRegionManager.md index c5409ca5..010de8be 100644 --- a/docs/Architecture/streamingRegionManager.md +++ b/docs/Architecture/streamingRegionManager.md @@ -86,8 +86,8 @@ let region = StreamingRegion( bounds: AABB(min: simd_float3(-50, 0, -50), max: simd_float3(50, 10, 50)), priority: 1, assets: [ - AssetReference(filename: "dungeon_room_A", withExtension: "usdz"), - AssetReference(filename: "dungeon_room_A_props", withExtension: "usdz"), + AssetReference(filename: "dungeon_room_A", withExtension: "untold"), + AssetReference(filename: "dungeon_room_A_props", withExtension: "untold"), ], estimatedMemorySize: 40_000_000 // 40 MB estimate ) @@ -175,7 +175,7 @@ let stats = StreamingRegionManager.shared.getStats() ## Relationship to `GeometryStreamingSystem` -`StreamingRegionManager` is independent of `GeometryStreamingSystem`. They can run simultaneously — for example, a tile-streamed outdoor scene (`setEntityStreamScene`) with handcrafted interior sectors (`StreamingRegionManager`). Both systems share `MemoryBudgetManager`, so memory pressure from one is visible to the other. +`StreamingRegionManager` is independent of `GeometryStreamingSystem`. It loads region assets through `setEntityMeshAsync`, so region assets are immediate/cache-backed `.untold` loads rather than tile-owned OCC stubs. It can run alongside a tile-streamed outdoor scene (`setEntityStreamScene`) with handcrafted interior sectors. Both systems share `MemoryBudgetManager`, so memory pressure from one is visible to the other. `StreamingRegionManager` does **not** use the octree, frustum gating, prefetch radius, grace-period teardown, or HLOD/LOD systems. It is intentionally simpler. For any of those features, use `setEntityStreamScene` with a manifest. diff --git a/docs/Architecture/textureStreamingSystem.md b/docs/Architecture/textureStreamingSystem.md index b4feb229..da577867 100644 --- a/docs/Architecture/textureStreamingSystem.md +++ b/docs/Architecture/textureStreamingSystem.md @@ -284,7 +284,7 @@ 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()` | +| OS `.critical` pressure callback | 20 | `MemoryBudgetManager.onMemoryPressureCritical` fires — aggressive shed + double geometry eviction pass (16 evictions each) | 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). @@ -432,4 +432,3 @@ The engine ships a native ASTC texture loader (`NativeTexFormat.swift`, `NativeT - **macOS / iOS:** 256 px This ensures every freshly loaded entity starts at the streaming system's minimum tier. The streaming system then only **upgrades** as the camera approaches — it never issues an immediate downgrade on a newly-loaded entity (which would have been visible as a resolution pop on the first frame the entity appeared). - diff --git a/docs/Architecture/tilebasedstreaming.md b/docs/Architecture/tilebasedstreaming.md index f859084e..4ec6d64d 100644 --- a/docs/Architecture/tilebasedstreaming.md +++ b/docs/Architecture/tilebasedstreaming.md @@ -194,7 +194,7 @@ Both `.parsing` and `.parsed` tiles go through the **grace period** (see [Unload 2. Creates a dedicated child *mesh entity* under the tile stub (`capturedMeshEntityId`) inside `withWorldMutationGate`. This guarantees `unloadTile`'s `collectTileDescendants` always has at least one child to destroy, regardless of how many submeshes the tile contains. 3. Registers `capturedMeshEntityId → tileEntityId` in `meshEntityToTileEntity` for O(1) OCC upload counter updates. 4. Spawns a Swift `Task` calling `setEntityMeshAsync(entityId: capturedMeshEntityId, streamingPolicy: .auto, blockRenderLoop: false)`. - - `.auto` policy: the admission gate chooses `fullLoad` (parse + immediate GPU upload) or `outOfCore` (parse to CPU heap, upload stubs via `StreamingComponent`) based on tile file size and available RAM. + - `.auto` policy: the admission gate chooses `fullLoad` (parse + immediate GPU upload) or `outOfCore` (store native `RuntimeAssetNode` CPU data and upload child stubs via `StreamingComponent`) based on renderable node count and geometry budget fraction. - `blockRenderLoop: false` — tile parses do **not** hold the `AssetLoadingGate` open. Without this, concurrent tile parses would keep `isLoadingAny == true` for their full duration, freezing `visibleEntityIds` updates and stalling the render loop. LOD and HLOD loads also use `blockRenderLoop: false` for the same reason. 5. Completion callback (fires on the main thread): - **Zombie-state guard** — checks `tc.state == .parsing`. If `unloadTile` ran while the parse was in flight, the state will be `.unloading`. The callback discards the result, destroys the pre-created child entity, and returns without marking the tile loaded. @@ -218,7 +218,7 @@ Each completed OCC upload calls `incrementParentTileOCCCount(for:)`, which incre 2. Sets `tileComp.state = .unloading`; cancels `tileComp.loadTask`. 3. If `wasParsing`: removes from `loadingTileEntities` and bails out. The Task completion callback will find `.unloading`, discard the result, and dispatch deferred child-entity cleanup — this avoids a concurrent ECS write race since `setEntityMeshAsync` may still be running. 4. If `.parsed`: calls `collectTileDescendants(entityId)` to walk the child tree, cancelling any in-flight OCC streaming tasks. Calls `destroyEntity` on all descendants + `finalizePendingDestroys()`. This releases GPU buffers, removes octree entries, releases `MeshResourceManager` refs, and unregisters from `MemoryBudgetManager`. -5. Calls `ProgressiveAssetLoader.shared.removeOutOfCoreAsset(rootEntityId:)` to free CPU-heap asset data for out-of-core tiles. +5. Calls `ProgressiveAssetLoader.shared.removeOutOfCoreAsset(rootEntityId:)` to free `CPURuntimeEntry` data for out-of-core tiles. 6. Resets `totalOCCStubs`, `uploadedOCCStubs`, `pendingUnloadSince` to 0. 7. Sets `tileComp.state = .unloaded`; removes from `loadedTileEntities`. diff --git a/scripts/export-untold-tiles b/scripts/export-untold-tiles index 22de92e9..d70e5aef 100755 --- a/scripts/export-untold-tiles +++ b/scripts/export-untold-tiles @@ -36,6 +36,10 @@ Common tile exporter flags: Radii are always proportional to scene size — no fixed distances to tweak. 'auto' infers the profile; use 'outdoor' to force city/open-world bands if auto-detection misses. + --tier-radius TIER=STREAM,UNLOAD[,PRIORITY] + Override semantic-tier radii in metres. May be repeated. + Example: --tier-radius StructuralInterior=10,16 + --tier-radius RoomContents=5,9,8 --floor-count N Pin the number of floors (inline quadtree mode). Use when auto-detection is wrong due to outlier objects inflating the Z range. --floor-band-height N Pin the per-floor height in metres. Floor count is derived diff --git a/scripts/tests/test_tilestreamingpartition.py b/scripts/tests/test_tilestreamingpartition.py index ca1387ec..c2b282f2 100644 --- a/scripts/tests/test_tilestreamingpartition.py +++ b/scripts/tests/test_tilestreamingpartition.py @@ -248,6 +248,7 @@ def test_parse_args_defaults(self) -> None: self.assertFalse(args.compress_geometry) self.assertFalse(args.quadtree) self.assertEqual(args.scene_profile, "auto") + self.assertEqual(args.tier_radius, []) def test_parse_args_tile_size_and_flags(self) -> None: args = t.parse_args([ @@ -259,6 +260,8 @@ def test_parse_args_tile_size_and_flags(self) -> None: "--compress-geometry", "--quadtree", "--scene-profile", "outdoor", + "--tier-radius", "StructuralInterior=10,16", + "--tier-radius", "RoomContents=5,9,8", "--dry-run", "--parallel-workers", "4", ]) @@ -269,9 +272,32 @@ def test_parse_args_tile_size_and_flags(self) -> None: self.assertTrue(args.compress_geometry) self.assertTrue(args.quadtree) self.assertEqual(args.scene_profile, "outdoor") + self.assertEqual(args.tier_radius, [ + ("StructuralInterior", {"streaming": 10.0, "unload": 16.0}), + ("RoomContents", {"streaming": 5.0, "unload": 9.0, "priority": 8}), + ]) self.assertTrue(args.dry_run) self.assertEqual(args.parallel_workers, 4) + def test_compute_tier_radii_applies_absolute_overrides(self) -> None: + previous = dict(t.TIER_RADIUS_OVERRIDES) + try: + t.TIER_RADIUS_OVERRIDES.clear() + t.TIER_RADIUS_OVERRIDES.update({ + "StructuralInterior": {"streaming": 10.0, "unload": 16.0}, + "RoomContents": {"streaming": 5.0, "unload": 9.0, "priority": 8}, + }) + radii = t.compute_tier_radii(scene_half_diag=100.0, profile="indoor") + self.assertEqual(radii["StructuralInterior"]["streaming"], 10.0) + self.assertEqual(radii["StructuralInterior"]["unload"], 16.0) + self.assertEqual(radii["StructuralInterior"]["priority"], 10) + self.assertEqual(radii["RoomContents"]["streaming"], 5.0) + self.assertEqual(radii["RoomContents"]["unload"], 9.0) + self.assertEqual(radii["RoomContents"]["priority"], 8) + finally: + t.TIER_RADIUS_OVERRIDES.clear() + t.TIER_RADIUS_OVERRIDES.update(previous) + def test_parse_args_blender_separator(self) -> None: # When invoked via Blender, argv contains "--" before the script args args = t.parse_args([ diff --git a/scripts/tilestreamingpartition.py b/scripts/tilestreamingpartition.py index 4125c483..aa0dc8df 100755 --- a/scripts/tilestreamingpartition.py +++ b/scripts/tilestreamingpartition.py @@ -251,6 +251,7 @@ def append_worker_progress(progress_file, event): }, } SCENE_STREAMING_PROFILE = "auto" # auto | indoor | outdoor +TIER_RADIUS_OVERRIDES: dict = {} # tier -> {"streaming": metres, "unload": metres, optional "priority": int} _ACTIVE_TIER_RADII: dict = {} # Tiers for which HLOD and LOD variants are generated during quadtree export. @@ -2502,7 +2503,7 @@ def infer_streaming_profile(use_quadtree, node_tier_groups, scene_half_diag, bas def compute_tier_radii(scene_half_diag, profile): """Convert fraction table for *profile* to world-space metres.""" fractions = TIER_STREAMING_FRACTIONS.get(profile, TIER_STREAMING_FRACTIONS["indoor"]) - return { + radii = { tier: { "streaming": max(1.0, scene_half_diag * v["streaming"]), "unload": max(2.0, scene_half_diag * v["unload"]), @@ -2510,6 +2511,22 @@ def compute_tier_radii(scene_half_diag, profile): } for tier, v in fractions.items() } + for tier, override in TIER_RADIUS_OVERRIDES.items(): + fallback_fraction = fractions.get(DEFAULT_SEMANTIC_TIER, {"streaming": 0.1, "unload": 0.18}) + existing = radii.get(tier, { + "streaming": max(1.0, scene_half_diag * fallback_fraction["streaming"]), + "unload": max(2.0, scene_half_diag * fallback_fraction["unload"]), + "priority": DEFAULT_STREAMING_PRIORITY, + }) + streaming = float(override["streaming"]) + unload = float(override["unload"]) + priority = int(override.get("priority", existing.get("priority", DEFAULT_STREAMING_PRIORITY))) + radii[tier] = { + "streaming": max(1.0, streaming), + "unload": max(max(2.0, unload), max(1.0, streaming) + 0.1), + "priority": priority, + } + return radii def init_tier_radii(scene_half_diag, profile): @@ -2521,6 +2538,47 @@ def tier_streaming_radii(tier): return _ACTIVE_TIER_RADII.get(tier, {}) +def parse_tier_radius_override(raw_value): + """Parse Tier=stream,unload[,priority] from the CLI.""" + if "=" not in raw_value: + raise argparse.ArgumentTypeError( + f"Invalid --tier-radius '{raw_value}'. Expected Tier=stream,unload[,priority]." + ) + tier, values = raw_value.split("=", 1) + tier = tier.strip() + if not tier: + raise argparse.ArgumentTypeError("Invalid --tier-radius: tier name is empty.") + parts = [p.strip() for p in values.split(",") if p.strip()] + if len(parts) not in (2, 3): + raise argparse.ArgumentTypeError( + f"Invalid --tier-radius '{raw_value}'. Expected Tier=stream,unload[,priority]." + ) + try: + streaming = float(parts[0]) + unload = float(parts[1]) + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"Invalid --tier-radius '{raw_value}'. Stream and unload must be numbers." + ) from exc + if streaming <= 0 or unload <= 0: + raise argparse.ArgumentTypeError( + f"Invalid --tier-radius '{raw_value}'. Stream and unload must be positive metres." + ) + if unload <= streaming: + raise argparse.ArgumentTypeError( + f"Invalid --tier-radius '{raw_value}'. Unload radius must be greater than streaming radius." + ) + override = {"streaming": streaming, "unload": unload} + if len(parts) == 3: + try: + override["priority"] = int(parts[2]) + except ValueError as exc: + raise argparse.ArgumentTypeError( + f"Invalid --tier-radius '{raw_value}'. Priority must be an integer." + ) from exc + return tier, override + + def log_streaming_profile(scene_bounds, scene_half_diag, resolved_profile): """Print a human-readable summary of the resolved tier streaming radii.""" bx = scene_bounds["max"][0] - scene_bounds["min"][0] @@ -2534,14 +2592,13 @@ def log_streaming_profile(scene_bounds, scene_half_diag, resolved_profile): f" Scene dimensions : {bx:.1f}m (W) × {by:.1f}m (D) × {bz:.1f}m (H)\n" f" Footprint half-diag : {scene_half_diag:.1f}m ← multiplier base" ) - fractions = TIER_STREAMING_FRACTIONS.get(resolved_profile, TIER_STREAMING_FRACTIONS["indoor"]) - for tier, v in fractions.items(): - s = max(1.0, scene_half_diag * v["streaming"]) - u = max(2.0, scene_half_diag * v["unload"]) + for tier, radii in _ACTIVE_TIER_RADII.items(): + override_suffix = " (override)" if tier in TIER_RADIUS_OVERRIDES else "" print( f" {tier:25s}: " - f"{v['streaming']:.2f} × {scene_half_diag:.1f}m = {s:7.1f}m stream | " - f"{v['unload']:.2f} × {scene_half_diag:.1f}m = {u:7.1f}m unload" + f"{radii['streaming']:7.1f}m stream | " + f"{radii['unload']:7.1f}m unload | " + f"priority={radii.get('priority', DEFAULT_STREAMING_PRIORITY)}{override_suffix}" ) @@ -3768,6 +3825,8 @@ def run(): "requested": SCENE_STREAMING_PROFILE, "resolved": resolved_profile, "scene_half_diag": round(scene_half_diag, 3), + "tier_radius_overrides": TIER_RADIUS_OVERRIDES, + "tier_radii": _ACTIVE_TIER_RADII, }, "streaming_defaults": { "streaming_radius": streaming_r, @@ -4490,6 +4549,18 @@ def parse_args(argv): "Radii are always proportional to scene_half_diag — no hardcoded distances." ), ) + parser.add_argument( + "--tier-radius", + action="append", + type=parse_tier_radius_override, + default=[], + metavar="TIER=STREAM,UNLOAD[,PRIORITY]", + help=( + "Override one semantic tier's stream/unload radii in metres. " + "May be repeated. Example: " + "--tier-radius StructuralInterior=10,16 --tier-radius RoomContents=5,9,8" + ), + ) parser.add_argument( "--floor-count", type=int, @@ -4540,6 +4611,7 @@ def apply_cli_overrides(args): global PERIMETER_DEPTH global FORCE_QUADTREE global SCENE_STREAMING_PROFILE + global TIER_RADIUS_OVERRIDES global INLINE_FLOOR_COUNT_OVERRIDE global INLINE_FLOOR_BAND_HEIGHT_OVERRIDE @@ -4585,6 +4657,9 @@ def apply_cli_overrides(args): FORCE_QUADTREE = True if getattr(args, "scene_profile", None): SCENE_STREAMING_PROFILE = args.scene_profile + if getattr(args, "tier_radius", None): + for tier, override in args.tier_radius: + TIER_RADIUS_OVERRIDES[tier] = override if getattr(args, "floor_count", None) is not None: INLINE_FLOOR_COUNT_OVERRIDE = args.floor_count if getattr(args, "floor_band_height", None) is not None: