From 85a9dc3a31bfa10b9d6b87d5a26a0d5be2f8b9cc Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 06:35:05 -0700 Subject: [PATCH 01/10] [Patch] Added AA parameters to serializer --- .../UntoldEngine/Scenes/SceneSerializer.swift | 58 ++++++++++++++++++ .../SceneSerializerTest.swift | 61 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 914becc2..25ec8afe 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 @@ -880,6 +903,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 @@ -1079,6 +1121,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 diff --git a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift index d65e3490..f841b4e0 100644 --- a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift +++ b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift @@ -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() { From 741bc1b0a328848fdac1db5070adc909ba91046c Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 22:40:37 -0700 Subject: [PATCH 02/10] [Patch] Set ambient default value to 0.4 --- Sources/UntoldEngine/Scenes/SceneSerializer.swift | 2 +- Sources/UntoldEngine/Utils/Globals.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 25ec8afe..fe83c904 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -1056,7 +1056,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 { 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? From 46a54a7d9b5e1acb655c088670d75a50c61e9e6f Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 22:44:04 -0700 Subject: [PATCH 03/10] [Patch] Use recursive traversal for derived asset node ids in serialization --- .../UntoldEngine/Scenes/SceneSerializer.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index fe83c904..aa328ced 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -377,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() @@ -766,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 { @@ -1711,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 } From aa031419ce68ff52299eac14201876d827fd4200 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 19 May 2026 10:01:56 -0700 Subject: [PATCH 04/10] WIP-removing usdz streaming --- Sources/UntoldEngine/Mesh/Mesh.swift | 852 +------- .../Scenes/Builder/MeshNode.swift | 2 +- .../UntoldEngine/Scenes/SceneSerializer.swift | 50 +- .../UntoldEngine/Systems/AssetProfiler.swift | 197 +- ...eometryStreamingSystem+MeshStreaming.swift | 431 +---- .../Systems/GeometryStreamingSystem.swift | 63 +- .../Systems/MeshResourceManager.swift | 19 +- .../Systems/ProgressiveAssetLoader.swift | 435 +---- .../Systems/RegistrationSystem.swift | 1709 ++++------------- .../AnimationTest.swift | 22 +- .../AsyncLoadingSystemTest.swift | 293 --- .../AsyncMeshLoadingTest.swift | 30 +- .../BaseRenderSetup.swift | 14 +- .../GPUMemoryTest.swift | 20 +- .../GeometryStreamingEvictionTests.swift | 39 +- .../GeometryStreamingTest.swift | 112 +- .../NativeFormatHierarchyTests.swift | 12 +- .../NativeFormatRegistrationTests.swift | 22 +- .../SceneSerializerTest.swift | 2 +- .../StaticBatchingTest.swift | 294 +-- .../StreamLodBatchTests.swift | 101 - .../USDZTextureTest.swift | 14 +- .../ProgressiveAssetLoaderTests.swift | 734 +------ 23 files changed, 725 insertions(+), 4742 deletions(-) delete mode 100644 Tests/UntoldEngineRenderTests/AsyncLoadingSystemTest.swift 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 aa328ced..19842888 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -1210,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() @@ -1266,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) diff --git a/Sources/UntoldEngine/Systems/AssetProfiler.swift b/Sources/UntoldEngine/Systems/AssetProfiler.swift index aac64baa..5dbc6bf6 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. @@ -254,83 +144,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..cc1c425b 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,377 +231,58 @@ 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 func loadMeshAsync( entityId: EntityID, @@ -621,29 +295,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 +367,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..cf48f14b 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: Sendable { + 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..7085eb14 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,7 @@ 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 +553,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 +564,162 @@ 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. +@discardableResult +private func registerUntoldProgressiveStubEntity( + node: RuntimeAssetNode, + index: Int, + uniqueAssetName: String, + rootEntityId: EntityID, + url _: URL, + filename: String, + withExtension ext: String +) -> EntityID { + let childEntityId = createEntity() + + ensureUntoldNodeComponents(entityId: childEntityId) + applyWorldTransform(node.worldTransform, 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: rootEntityId) + + 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 stub with StreamingComponent(.unloaded) and a +/// CPURuntimeEntry in ProgressiveAssetLoader. GeometryStreamingSystem drives GPU upload via +/// uploadFromRuntimeEntry when each entity enters streaming range. +@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 runtimeAsset.nodes.count == 1, node.parentID == nil { + // Single-node asset: root entity is the stub. + let uniqueName = node.name + let estimatedBytes = node.primitives.reduce(0) { $0 + $1.estimatedGPUBytes } + + applyWorldTransform(node.worldTransform, to: entityId) + if let local = scene.get(component: LocalTransformComponent.self, for: entityId) { + local.boundingBox = (min: node.localBounds.min, max: node.localBounds.max) + } + setEntityName(entityId: entityId, name: uniqueName) + + registerComponent(entityId: entityId, componentType: StreamingComponent.self) + if let sc = scene.get(component: StreamingComponent.self, for: entityId) { + sc.assetFilename = filename + sc.assetExtension = ext + sc.assetName = uniqueName + sc.state = .unloaded + sc.streamingRadius = Float.greatestFiniteMagnitude + sc.unloadRadius = Float.greatestFiniteMagnitude + } + + let entry = ProgressiveAssetLoader.CPURuntimeEntry( + node: node, + url: url, + uniqueAssetName: uniqueName, + estimatedGPUBytes: estimatedBytes, + residencyPolicy: residencyPolicy + ) + ProgressiveAssetLoader.shared.storeCPURuntimeEntry(entry, for: entityId) + childIds.append(entityId) + entityByNodeID[node.id] = entityId + + } else 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 — OCC stub. + let uniqueName = "\(node.name)#\(index)" + let childEntityId = registerUntoldProgressiveStubEntity( + node: node, + index: index, + uniqueAssetName: uniqueName, + 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, @@ -1036,988 +897,193 @@ private func registerUntoldRuntimeAsset( registerRuntimeSkeletonIfNeeded( entityId: targetEntityId, skeleton: resolvedRuntimeSkeleton(for: node, nodesByID: nodesByID) - ) - continue - } - - _ = registerUntoldNodePayload(entityId: targetEntityId, node: node, nodesByID: nodesByID, url: url) - } - - // 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 - // not yet been updated from its localTransform (e.g. the 90° X rotation baked - // into the Armature node by the exporter). Re-running the propagation from the - // root after the loop ensures every descendant inherits the correct world transform. - syncWorldTransformAndMarkOctreeDirty(entityId: entityId) - - return true -} - -/// Generate a stable node path for a derived mesh node -func generateStableNodePath(assetName: String, index: Int) -> String { - // Use a deterministic format: "Root/#" - // This ensures the same USDZ file produces the same nodePath each time - "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) + ) + continue + } - // Continue to the validation + registration block below using these meshes. - // ─── SMALL-ASSET CONTINUATION ────────────────────────────────────────────── - let meshes = smallAssetMeshes + _ = registerUntoldNodePayload(entityId: targetEntityId, node: node, nodesByID: nodesByID, url: url) + } - if meshes.isEmpty { - handleError(.assetDataMissing, filename) - loadFallbackMesh(entityId: entityId, filename: filename) - await AssetLoadingState.shared.finishLoading(entityId: entityId) - completionBox?.call(false) - return + // 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] } + } + } - let nonEmptyMeshes = meshes.filter { !$0.isEmpty } + // 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 + // not yet been updated from its localTransform (e.g. the 90° X rotation baked + // into the Armature node by the exporter). Re-running the propagation from the + // root after the loop ensures every descendant inherits the correct world transform. + syncWorldTransformAndMarkOctreeDirty(entityId: entityId) - // assetName is nil here (progressive path requires nil assetName). + return true +} - await AssetLoadingState.shared.updateProgress(entityId: entityId, currentMesh: 0, totalMeshes: nonEmptyMeshes.count, phase: .registering) +/// Generate a stable node path for a derived mesh node +func generateStableNodePath(assetName: String, index: Int) -> String { + // Use a deterministic format: "Root/#" + // This ensures the same USDZ file produces the same nodePath each time + "Root/\(assetName)#\(index)" +} - var loadingEntityIds: [EntityID] = [entityId] - let handledImportedLOD = tryRegisterImportedLODGroup( - entityId: entityId, - url: url, - filename: filename, - withExtension: withExtension, - nonEmptyMeshes: nonEmptyMeshes - ) +/// 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. - 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) - } - } +/// 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 - for id in loadingEntityIds { - if let renderComp = scene.get(component: RenderComponent.self, for: id) { - renderComp.isVisible = true - } - } + /// 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) - 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,23 +1838,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,88 +1867,7 @@ 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 @@ -2942,34 +1910,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 +2265,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 +2584,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 +2781,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/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..370b7e9d 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -460,20 +460,22 @@ 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") + setEntityMeshDirect(entityId: stadium, meshes: BasicPrimitives.createCube(), assetName: "stadium") translateBy(entityId: stadium, position: simd_float3(0.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") + let playerExp = XCTestExpectation(description: "redplayer loaded") + setEntityMeshAsync(entityId: player, filename: "redplayer", withExtension: "untold") { _ in playerExp.fulfill() } + let _ = XCTWaiter.wait(for: [playerExp], timeout: 10) - // Ball (named for lookup) + // Ball — sphere placeholder let ball = createEntity() - setEntityMesh(entityId: ball, filename: "ball", withExtension: "untold") + setEntityMeshDirect(entityId: ball, meshes: BasicPrimitives.createSphere(), assetName: "ball") setEntityName(entityId: ball, name: "ball") translateBy(entityId: ball, position: simd_float3(0.0, 0.4, 3.0)) 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..388df27e 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" ) } diff --git a/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift b/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift index 4b8547b3..5c714b41 100644 --- a/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift +++ b/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift @@ -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 entity = createEntity() + 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") renderComponent.assetName = "ball" } @@ -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 + // 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") } _ = 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 entity = createEntity() + 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") } 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.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") } 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 entity = createEntity() + 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") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -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 entity = createEntity() + 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") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -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 { + 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") } 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/SceneSerializerTest.swift b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift index f841b4e0..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 { diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index f2d81253..5ce5118b 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 - ) + // Load meshes once so all entities share the same texture object identity + 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 - ) + // Load meshes once so all entities share the same texture object identity + 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) - 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) + 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.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,30 +1118,20 @@ 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.setBatchCellSize(10.0) BatchingSystem.shared.setMaxDirtyCellsPerTick(4) BatchingSystem.shared.setMaxRebuildVerticesPerTick(1_000_000_000) BatchingSystem.shared.setMaxRebuildIndicesPerTick(1_000_000_000) 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,30 +1180,20 @@ 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.setBatchCellSize(10.0) BatchingSystem.shared.setMaxRuntimeCellVertices(1) BatchingSystem.shared.setMaxRuntimeCellIndices(1_000_000_000) BatchingSystem.shared.setMaxRuntimeCellBufferBytes(1_000_000_000) 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,12 +1252,7 @@ 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.setBatchCellSize(10.0) BatchingSystem.shared.setBackgroundArtifactBuildEnabled(true) BatchingSystem.shared.setMaxDirtyCellsPerTick(1) BatchingSystem.shared.setMaxBuildDispatchesPerTick(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.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..439f988a 100644 --- a/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift +++ b/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift @@ -558,107 +558,6 @@ 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") - } -} // 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) } } From 5562f01cccaccbe043c7d67ef93279fe77b9e2d3 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 19 May 2026 13:41:19 -0700 Subject: [PATCH 05/10] [Patch] Route native OCC stubs through child entities --- .../Systems/RegistrationSystem.swift | 79 ++++++++-------- .../BaseRenderSetup.swift | 4 +- .../GeometryStreamingEvictionTests.swift | 93 +++++++++++++++++++ .../GeometryStreamingTest.swift | 30 +++--- 4 files changed, 148 insertions(+), 58 deletions(-) diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 7085eb14..6f8bc7c0 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -568,11 +568,16 @@ func makeMeshes(from node: RuntimeAssetNode) -> [Mesh] { /// /// 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, @@ -581,14 +586,28 @@ private func registerUntoldProgressiveStubEntity( let childEntityId = createEntity() ensureUntoldNodeComponents(entityId: childEntityId) - applyWorldTransform(node.worldTransform, to: 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: rootEntityId) + 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) @@ -613,9 +632,13 @@ private func registerUntoldProgressiveStubEntity( /// Register all renderable nodes in a .untold RuntimeAsset as OCC stub entities. /// -/// Each node with primitives becomes a zero-GPU stub with StreamingComponent(.unloaded) and a -/// CPURuntimeEntry in ProgressiveAssetLoader. GeometryStreamingSystem drives GPU upload via -/// uploadFromRuntimeEntry when each entity enters streaming range. +/// 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, @@ -640,39 +663,7 @@ private func registerUntoldRuntimeAssetOCC( let residencyPolicy = AssetLoadingPolicy(geometry: .streaming, texture: .eager, source: .auto) for node in runtimeAsset.nodes { - if runtimeAsset.nodes.count == 1, node.parentID == nil { - // Single-node asset: root entity is the stub. - let uniqueName = node.name - let estimatedBytes = node.primitives.reduce(0) { $0 + $1.estimatedGPUBytes } - - applyWorldTransform(node.worldTransform, to: entityId) - if let local = scene.get(component: LocalTransformComponent.self, for: entityId) { - local.boundingBox = (min: node.localBounds.min, max: node.localBounds.max) - } - setEntityName(entityId: entityId, name: uniqueName) - - registerComponent(entityId: entityId, componentType: StreamingComponent.self) - if let sc = scene.get(component: StreamingComponent.self, for: entityId) { - sc.assetFilename = filename - sc.assetExtension = ext - sc.assetName = uniqueName - sc.state = .unloaded - sc.streamingRadius = Float.greatestFiniteMagnitude - sc.unloadRadius = Float.greatestFiniteMagnitude - } - - let entry = ProgressiveAssetLoader.CPURuntimeEntry( - node: node, - url: url, - uniqueAssetName: uniqueName, - estimatedGPUBytes: estimatedBytes, - residencyPolicy: residencyPolicy - ) - ProgressiveAssetLoader.shared.storeCPURuntimeEntry(entry, for: entityId) - childIds.append(entityId) - entityByNodeID[node.id] = entityId - - } else if node.primitives.isEmpty { + if node.primitives.isEmpty { // Container node — hierarchy entity only, no StreamingComponent. let containerEntityId = createEntity() ensureUntoldNodeComponents(entityId: containerEntityId) @@ -683,12 +674,18 @@ private func registerUntoldRuntimeAssetOCC( entityByNodeID[node.id] = containerEntityId } else { - // Renderable node — OCC stub. - let uniqueName = "\(node.name)#\(index)" + // 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, @@ -976,7 +973,7 @@ public func setEntityMeshAsync( assetName: String? = nil, flip _: Bool = true, coordinateConversion: CoordinateSystemConversion = .autoDetect, - streamingPolicy: MeshStreamingPolicy = .auto, + streamingPolicy: MeshStreamingPolicy = .immediate, blockRenderLoop: Bool = true, completion: ((Bool) -> Void)? = nil ) { diff --git a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift index 370b7e9d..d8e3fa07 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -462,7 +462,7 @@ class BaseRenderSetup: XCTestCase { // Stadium — cube placeholder (no .usdz anymore) let stadium = createEntity() - setEntityMeshDirect(entityId: stadium, meshes: BasicPrimitives.createCube(), assetName: "stadium") + setEntityMeshAsync(entityId: stadium, filename: "stadium", withExtension: "untold") translateBy(entityId: stadium, position: simd_float3(0.0, 0.0, 0.0)) setEntityName(entityId: stadium, name: "stadium") @@ -475,7 +475,7 @@ class BaseRenderSetup: XCTestCase { // Ball — sphere placeholder let ball = createEntity() - setEntityMeshDirect(entityId: ball, meshes: BasicPrimitives.createSphere(), assetName: "ball") + setEntityMeshAsync(entityId: ball, filename: "ball", withExtension: "untold") setEntityName(entityId: ball, name: "ball") translateBy(entityId: ball, position: simd_float3(0.0, 0.4, 3.0)) diff --git a/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift b/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift index 388df27e..02322f56 100644 --- a/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift +++ b/Tests/UntoldEngineRenderTests/GeometryStreamingEvictionTests.swift @@ -334,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 5c714b41..2b3c0801 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") @@ -100,7 +100,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") renderComponent.assetName = "ball" } @@ -123,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() { @@ -196,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 @@ -213,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") @@ -248,7 +248,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -284,7 +284,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -335,7 +335,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { @@ -378,7 +378,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -406,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 @@ -414,7 +414,7 @@ final class GeometryStreamingTest: BaseRenderSetup { let highPriorityEntity = createStreamingEntity( filename: "model", - withExtension: "usdz", + withExtension: "untold", streamingRadius: 100.0, unloadRadius: 150.0, priority: 10 @@ -440,7 +440,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } _ = scene.assign(to: entity, component: LocalTransformComponent.self) _ = scene.assign(to: entity, component: WorldTransformComponent.self) @@ -469,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 @@ -504,7 +504,7 @@ final class GeometryStreamingTest: BaseRenderSetup { if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { renderComponent.mesh = meshes - renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball") + renderComponent.assetURL = URL(fileURLWithPath: "/dev/null/ball.untold") } if let transform = scene.assign(to: entity, component: LocalTransformComponent.self) { From 3a9fcf256026a254ecb295561dea326de69121ba Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 19 May 2026 13:42:11 -0700 Subject: [PATCH 06/10] [Patch] Reimplented setEntityMesh --- .../Systems/RegistrationSystem.swift | 49 +++++++++++++++++++ .../BaseRenderSetup.swift | 18 ++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 6f8bc7c0..d1a50481 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -965,6 +965,55 @@ public enum MeshStreamingPolicy: Sendable { case immediate } +/// 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 + } + + 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 + } + + guard let runtimeAsset = loadUntoldRuntimeAsset(url: url) else { + loadFallbackMesh(entityId: entityId, filename: filename) + return + } + + let didLoad = registerUntoldRuntimeAsset( + entityId: entityId, + runtimeAsset: runtimeAsset, + url: url, + filename: filename, + withExtension: withExtension, + assetName: assetName + ) + + if !didLoad { + loadFallbackMesh(entityId: entityId, filename: filename) + } +} + /// Asynchronously load and set entity mesh without blocking the main thread public func setEntityMeshAsync( entityId: EntityID, diff --git a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift index d8e3fa07..d220d2b3 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -462,23 +462,27 @@ class BaseRenderSetup: XCTestCase { // Stadium — cube placeholder (no .usdz anymore) let stadium = createEntity() - setEntityMeshAsync(entityId: stadium, filename: "stadium", withExtension: "untold") - 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) — load actual .untold asset so AnimationComponent is registered let player = createEntity() setEntityName(entityId: player, name: "player") - let playerExp = XCTestExpectation(description: "redplayer loaded") - setEntityMeshAsync(entityId: player, filename: "redplayer", withExtension: "untold") { _ in playerExp.fulfill() } - let _ = XCTWaiter.wait(for: [playerExp], timeout: 10) + setEntityMesh(entityId: player, filename: "redplayer", withExtension: "untold") // Ball — sphere placeholder let ball = createEntity() - setEntityMeshAsync(entityId: ball, filename: "ball", withExtension: "untold") + setEntityMesh(entityId: ball, filename: "ball", withExtension: "untold") setEntityName(entityId: ball, name: "ball") translateBy(entityId: ball, position: simd_float3(0.0, 0.4, 3.0)) - + + // helmet pbr // let helmet = createEntity() // setEntityMesh(entityId: helmet, filename: "helmet", withExtension: "untold") From 4a18984051588855afec912fef43ceb72af1d731 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 19 May 2026 13:42:41 -0700 Subject: [PATCH 07/10] [Chores] formatted files --- .../UntoldEngine/Systems/AssetProfiler.swift | 1 - ...eometryStreamingSystem+MeshStreaming.swift | 1 + .../Systems/ProgressiveAssetLoader.swift | 2 +- .../Systems/RegistrationSystem.swift | 24 +++++-------- .../BaseRenderSetup.swift | 11 +++--- .../GeometryStreamingTest.swift | 14 ++++---- .../StaticBatchingTest.swift | 36 +++++++++---------- .../StreamLodBatchTests.swift | 2 +- 8 files changed, 41 insertions(+), 50 deletions(-) diff --git a/Sources/UntoldEngine/Systems/AssetProfiler.swift b/Sources/UntoldEngine/Systems/AssetProfiler.swift index 5dbc6bf6..cd522adf 100644 --- a/Sources/UntoldEngine/Systems/AssetProfiler.swift +++ b/Sources/UntoldEngine/Systems/AssetProfiler.swift @@ -143,5 +143,4 @@ public enum AssetProfiler { return .eager } - } diff --git a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift index cc1c425b..fd793357 100644 --- a/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift +++ b/Sources/UntoldEngine/Systems/GeometryStreamingSystem+MeshStreaming.swift @@ -283,6 +283,7 @@ extension GeometryStreamingSystem { return scene.exists(entityId) } + /// Load mesh asynchronously - returns true on success, false on failure func loadMeshAsync( entityId: EntityID, diff --git a/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift b/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift index cf48f14b..5e53ce9b 100644 --- a/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift +++ b/Sources/UntoldEngine/Systems/ProgressiveAssetLoader.swift @@ -37,7 +37,7 @@ public final class ProgressiveAssetLoader: @unchecked Sendable { /// 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: Sendable { + struct CPURuntimeEntry { let node: RuntimeAssetNode let url: URL let uniqueAssetName: String diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index d1a50481..ab8db3e8 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -478,7 +478,6 @@ private func applyImportedTransformFromMeshGroup(_ meshGroup: [Mesh], to entityI @discardableResult - private func loadUntoldRuntimeAsset(url: URL) -> RuntimeAsset? { do { return try NativeFormatLoader().loadAssetSync(from: url) @@ -933,15 +932,14 @@ func generateStableNodePath(assetName: String, index: Int) -> String { "Root/\(assetName)#\(index)" } - -/// 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. +// 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. /// Controls how `setEntityMeshAsync` manages GPU residency for a loaded asset. public enum MeshStreamingPolicy: Sendable { @@ -1021,7 +1019,7 @@ public func setEntityMeshAsync( withExtension: String, assetName: String? = nil, flip _: Bool = true, - coordinateConversion: CoordinateSystemConversion = .autoDetect, + coordinateConversion _: CoordinateSystemConversion = .autoDetect, streamingPolicy: MeshStreamingPolicy = .immediate, blockRenderLoop: Bool = true, completion: ((Bool) -> Void)? = nil @@ -1884,7 +1882,6 @@ private func normalizeTileStreamingBands( return (normalizedPrefetch, normalizedHLOD, normalizedLODs) } - func removeEntityMesh(entityId: EntityID) { var removedAnyResourceOwner = false @@ -1913,8 +1910,6 @@ func removeEntityMesh(entityId: EntityID) { MemoryBudgetManager.shared.unregisterMesh(entityId: entityId) } - - 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 { @@ -1955,7 +1950,6 @@ public func setEntityAnimations(entityId: EntityID, filename: String, withExtens } return } - } func removeEntityAnimations(entityId: EntityID) { diff --git a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift index d220d2b3..8ba1762b 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -463,13 +463,11 @@ class BaseRenderSetup: XCTestCase { // Stadium — cube placeholder (no .usdz anymore) let stadium = createEntity() 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)) + + rotateBy(entityId: stadium, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) setEntityName(entityId: stadium, name: "stadium") - - // Player (animated) — load actual .untold asset so AnimationComponent is registered let player = createEntity() @@ -481,8 +479,7 @@ class BaseRenderSetup: XCTestCase { setEntityMesh(entityId: ball, filename: "ball", withExtension: "untold") setEntityName(entityId: ball, name: "ball") translateBy(entityId: ball, position: simd_float3(0.0, 0.4, 3.0)) - - + // helmet pbr // let helmet = createEntity() // setEntityMesh(entityId: helmet, filename: "helmet", withExtension: "untold") diff --git a/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift b/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift index 2b3c0801..ef811a40 100644 --- a/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift +++ b/Tests/UntoldEngineRenderTests/GeometryStreamingTest.swift @@ -95,7 +95,7 @@ final class GeometryStreamingTest: BaseRenderSetup { func testEnableStreamingForSingleMeshEntity() { // Given: A single-mesh entity with RenderComponent - let entity = createEntity() + let entity = createEntity() let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { @@ -241,7 +241,7 @@ final class GeometryStreamingTest: BaseRenderSetup { func testGetStatsWithStreamingEntities() { // Given: Create streaming entities with different states - // Create 2 loaded entities + // Create 2 loaded entities for _ in 0 ..< 2 { let entity = createEntity() let meshes = BasicPrimitives.createSphere() @@ -279,7 +279,7 @@ final class GeometryStreamingTest: BaseRenderSetup { func testStreamingUpdateUnloadsDistantEntities() { // Given: A loaded streaming entity - let entity = createEntity() + let entity = createEntity() let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { @@ -326,7 +326,7 @@ final class GeometryStreamingTest: BaseRenderSetup { } func testStreamingUpdateRespectsUnloadBudgetPerTick() { - GeometryStreamingSystem.shared.maxUnloadsPerUpdate = 1 + GeometryStreamingSystem.shared.maxUnloadsPerUpdate = 1 GeometryStreamingSystem.shared.enabled = true func makeLoadedEntity(positionX: Float) -> EntityID { @@ -373,7 +373,7 @@ final class GeometryStreamingTest: BaseRenderSetup { func testForceUnload() { // Given: A loaded streaming entity - let entity = createEntity() + let entity = createEntity() let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { @@ -435,7 +435,7 @@ final class GeometryStreamingTest: BaseRenderSetup { func testUnloadRadiusMustBeGreaterThanStreamingRadius() { // Given: An entity with streaming - let entity = createEntity() + let entity = createEntity() let meshes = BasicPrimitives.createSphere() if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { @@ -498,7 +498,7 @@ final class GeometryStreamingTest: BaseRenderSetup { func testStreamingIntegrationWithRendering() { // Given: Create streaming entities - for i in 0 ..< 3 { + for i in 0 ..< 3 { let entity = createEntity() let meshes = BasicPrimitives.createSphere() diff --git a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift index 5ce5118b..a27017cd 100644 --- a/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift +++ b/Tests/UntoldEngineRenderTests/StaticBatchingTest.swift @@ -168,7 +168,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testGenerateBatchesWithMultipleStaticEntities() { // Given: Load a simple model - // Load meshes once so all entities share the same texture object identity + // Load meshes once so all entities share the same texture object identity let meshes = BasicPrimitives.createSphere() // Create multiple entities with same mesh (same material) @@ -213,7 +213,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchGroupBufferCreation() { // Given: Load a model and create batched entities - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() for _ in 0 ..< 3 { let entity = createEntity() @@ -251,7 +251,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testClearBatches() { // Given: Create some batches - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() for _ in 0 ..< 3 { let entity = createEntity() @@ -433,7 +433,7 @@ final class StaticBatchingTest: BaseRenderSetup { // This test verifies that batching integrates with the rendering system // Given: Create batched entities - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() for i in 0 ..< 3 { let entity = createEntity() @@ -576,7 +576,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchStatistics() { // Given: Create entities and generate batches - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() let entityCount = 10 for _ in 0 ..< entityCount { @@ -619,7 +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 - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() // Create 4 entities at LOD 0 var lod0Entities: [EntityID] = [] @@ -697,7 +697,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchingSystemRemovesEntityOnMeshEviction() { // Given: Create batched entities - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() var entities: [EntityID] = [] for _ in 0 ..< 4 { @@ -757,7 +757,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testBatchingSystemAddsEntityWhenMeshBecomesResident() { // Given: Create an entity without mesh initially - // Load meshes once so all entities share the same texture object identity + // Load meshes once so all entities share the same texture object identity let meshes = BasicPrimitives.createSphere() // First, create some batched entities so there's a batch to join @@ -822,7 +822,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testQuiescenceDelaysPromotionToBatchPending() { - BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(2) + BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(2) let meshes = BasicPrimitives.createSphere() @@ -886,7 +886,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testTickProcessesPendingRemovalsAndAdditions() { // Given: Create batched entities // Note: tick() processes pending entity changes and rebuilds affected cells incrementally - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() // Create entities at LOD 0 var lod0Entities: [EntityID] = [] @@ -994,7 +994,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickRebuildsOnlyDirtyCells() { - BatchingSystem.shared.setBatchCellSize(10.0) + BatchingSystem.shared.setBatchCellSize(10.0) enableBatching(true) let meshes = BasicPrimitives.createSphere() @@ -1057,7 +1057,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickProcessesRemainingDirtyCellsAcrossFramesWhenBudgetLimited() { - BatchingSystem.shared.setBatchCellSize(10.0) + BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setMaxDirtyCellsPerTick(1) enableBatching(true) @@ -1118,7 +1118,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickDefersSomeDirtyCellsWhenWorkBudgetIsExceeded() { - BatchingSystem.shared.setBatchCellSize(10.0) + BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setMaxDirtyCellsPerTick(4) BatchingSystem.shared.setMaxRebuildVerticesPerTick(1_000_000_000) BatchingSystem.shared.setMaxRebuildIndicesPerTick(1_000_000_000) @@ -1180,7 +1180,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testTickMarksOversizedCellsRuntimeIneligible() { - BatchingSystem.shared.setBatchCellSize(10.0) + BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setMaxRuntimeCellVertices(1) BatchingSystem.shared.setMaxRuntimeCellIndices(1_000_000_000) BatchingSystem.shared.setMaxRuntimeCellBufferBytes(1_000_000_000) @@ -1252,7 +1252,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testBackgroundArtifactBuildAppliesOnSubsequentTick() { - BatchingSystem.shared.setBatchCellSize(10.0) + BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setBackgroundArtifactBuildEnabled(true) BatchingSystem.shared.setMaxDirtyCellsPerTick(1) BatchingSystem.shared.setMaxBuildDispatchesPerTick(1) @@ -1315,7 +1315,7 @@ final class StaticBatchingTest: BaseRenderSetup { } func testVisibilityGateDefersOffscreenCellBatchRebuild() { - BatchingSystem.shared.setBatchCellSize(10.0) + BatchingSystem.shared.setBatchCellSize(10.0) BatchingSystem.shared.setVisibilityGatedBatchBuildEnabled(true) BatchingSystem.shared.setQuiescenceFramesBeforeBatchBuild(0) enableBatching(true) @@ -1376,7 +1376,7 @@ final class StaticBatchingTest: BaseRenderSetup { func testGenerateBatchesCreatesSeparateBatchesForDifferentLODs() { // Given: Create entities with same material but different LOD levels - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() // Create entities at LOD 0 var lod0Entities: [EntityID] = [] @@ -1618,7 +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. - let meshes = BasicPrimitives.createSphere() + let meshes = BasicPrimitives.createSphere() var entities: [EntityID] = [] for i in 0 ..< 3 { diff --git a/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift b/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift index 439f988a..24672f02 100644 --- a/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift +++ b/Tests/UntoldEngineRenderTests/StreamLodBatchTests.swift @@ -557,7 +557,7 @@ final class StreamLodBatchLODAwareStreamingTests: BaseRenderSetup { // MARK: - LOD+OOC Integration Tests -/// Tests that verify the LOD+OOC integration path in ProgressiveAssetLoader. +// Tests that verify the LOD+OOC integration path in ProgressiveAssetLoader. // MARK: - Region Streaming Event Tests From 2ffc2cfbc0c9d088c5e88af0e556e191d76d64d1 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 19 May 2026 14:22:54 -0700 Subject: [PATCH 08/10] [Test] Add reference images --- .../ReferenceImages/BloomReference.png | Bin 2727447 -> 2170557 bytes .../ChromaticAberrationReference.png | Bin 1032020 -> 1049788 bytes .../ReferenceImages/ColorGradingReference.png | Bin 553286 -> 640268 bytes .../ReferenceImages/ColorTargetReference.png | Bin 78352 -> 78534 bytes .../CompositeColorTargetReference.png | Bin 175929 -> 179802 bytes .../ReferenceImages/DepthOfFieldReference.png | Bin 1382956 -> 1397986 bytes .../ReferenceImages/FXAAReference.png | Bin 720032 -> 755855 bytes .../LightPassColorReference.png | Bin 711303 -> 739789 bytes .../PositionTargetReference.png | Bin 219578 -> 219443 bytes .../ReferenceImages/SMAAReference.png | Bin 0 -> 716059 bytes .../ReferenceImages/SSAOReference.png | Bin 34593 -> 35284 bytes .../TransparencyTargetReference.png | Bin 711303 -> 739789 bytes .../ReferenceImages/VignetteReference.png | Bin 1714509 -> 1690488 bytes 13 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/Resources/ReferenceImages/SMAAReference.png diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/BloomReference.png index a2af98bf6f0cf0b023da0935082e7acc5fdcb1a3..2a7d25f2fde163bd76ecd6824f019a3e88301155 100644 GIT binary patch literal 2170557 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