From d6a073ef771ac2b834b5aa426cabb884eb3ec05f Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Tue, 24 Mar 2026 23:00:45 -0700 Subject: [PATCH 1/4] [Patch] Consolidate loadScene as the primary scene entry point --- Sources/DemoGame/GameScene.swift | 23 +- Sources/UntoldEngine/Mesh/Mesh.swift | 2 +- .../Systems/RegistrationSystem.swift | 284 ++++++++++++------ .../AsyncMeshLoadingTest.swift | 158 ++++++++++ 4 files changed, 351 insertions(+), 116 deletions(-) diff --git a/Sources/DemoGame/GameScene.swift b/Sources/DemoGame/GameScene.swift index babd848b..e5568058 100644 --- a/Sources/DemoGame/GameScene.swift +++ b/Sources/DemoGame/GameScene.swift @@ -13,7 +13,7 @@ /// Core Engine API map used by this demo: /// - Entity lifecycle: `createEntity`, `setEntityName`, `destroyAllEntities` /// - Camera/input: `createGameCamera`, `findGameCamera`, `moveCameraWithInput`, `orbitCameraAround` - /// - Asset loading: `setEntityMeshAsync` + /// - Asset loading: `loadScene` /// - Performance features: `setEntityStaticBatchComponent`, `enableBatching`, `generateBatches`, `enableStreaming` /// - Debug overlays: `setLODLevelDebug`, `setTextureStreamingTierDebug`, `setOctreeLeafBoundsDebug` final class GameScene { @@ -55,28 +55,19 @@ // MARK: - Asset Loading extension GameScene { - /// Loads a USDZ file into the scene, replacing any previously loaded model. - /// - /// Asset load lifecycle contract: - /// 1. `destroyAllEntities` completion means teardown is finished; only then rebuild scene entities. - /// 2. `setEntityMeshAsync` completion means mesh loading/streaming setup is complete; only then update UI. + /// Loads a USDZ file into the scene, replacing whatever was previously loaded. + /// destroyAllEntities, mesh loading, and default camera/light creation are all + /// handled internally by loadScene — no manual teardown needed here. func loadFile(path: String, completion: @escaping (Bool) -> Void) { clearSceneBatches() loadedEntity = nil - destroyAllEntities { [weak self] in + loadScene(filename: path, withExtension: Constants.usdzExtension) { [weak self] success in guard let self else { return } - setupDefaultSceneObjects() - + loadedEntity = findEntity(name: path) let camera = findGameCamera() setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) - - let entity = createEntity() - loadedEntity = entity - - setEntityMeshAsync(entityId: entity, filename: path, withExtension: Constants.usdzExtension) { isOutOfCore in - completion(isOutOfCore) - } + completion(success) } } } diff --git a/Sources/UntoldEngine/Mesh/Mesh.swift b/Sources/UntoldEngine/Mesh/Mesh.swift index ebac8f04..fcb07518 100644 --- a/Sources/UntoldEngine/Mesh/Mesh.swift +++ b/Sources/UntoldEngine/Mesh/Mesh.swift @@ -38,7 +38,7 @@ public enum CoordinateSystemConversion: Sendable { case none // Use transforms as-is from USD } -private func orientationTransformForAsset(_ asset: MDLAsset, conversion: CoordinateSystemConversion) -> simd_float4x4 { +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 diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 6fa6dc10..b2a1eed4 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -12,6 +12,7 @@ import CShaderTypes import Foundation import MetalKit +import ModelIO @inline(__always) private func enforceRegistrationMainActor() { @@ -1670,129 +1671,214 @@ public func setEntityMeshDirect(entityId: EntityID, meshes: [Mesh], assetName: S } } -public func loadScene(filename: String, withExtension: String, coordinateConversion: CoordinateSystemConversion = .autoDetect) { - guard let url: URL = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { - handleError(.filenameNotFound, filename) - return - } - - if url.pathExtension == "dae" { - handleError(.fileTypeNotSupported, url.pathExtension) - return - } - - var meshes = [[Mesh]]() - - meshes = Mesh.loadSceneMeshes(url: url, vertexDescriptor: vertexDescriptor.model, device: renderInfo.device, coordinateConversion: coordinateConversion) - - // 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 - } - - for mesh in meshes { - if mesh.count > 0 { - let entityId = createEntity() - - 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) - - setEntityName(entityId: entityId, name: mesh.first!.assetName) - - // look for any skeletons in asset - setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) - } - } -} - -/// Asynchronously load a scene without blocking the main thread -public func loadSceneAsync( +/// Load a USDZ scene, replacing whatever is currently in the world. +/// +/// Operation sequence: +/// 1. destroyAllEntities — clears the previous scene. +/// 2. extractSceneCamerasAndLights — lightweight MDLAsset pass (no geometry buffers) +/// run synchronously before mesh loading so camera/light entities exist during +/// the entire async mesh load period. +/// • importCameras: true → use USDZ cameras; first found wins. Fall back to a default if none found. +/// • importCameras: false → always create a default camera (skip USDZ scan). +/// • importLights: true → use USDZ lights; fall back to a default dir-light if none found. +/// • importLights: false → always create a default dir-light (skip USDZ scan). +/// 3. setEntityMeshAsync — loads geometry via the full OOC pipeline. +/// +/// The root entity is named after `filename`. Use findEntity(name: filename) in the +/// completion block to obtain a reference to it if needed. +/// +/// - Parameters: +/// - filename: USDZ filename without extension. +/// - withExtension: File extension (typically "usdz"). +/// - importCameras: Try to import cameras from the USDZ; fall back to default. +/// - importLights: Try to import lights from the USDZ; fall back to default. +/// - enableBatching: Call enableBatching(true) + generateBatches() after loading. +/// - enableGeometryStreaming: Force .outOfCore streaming policy (overrides .auto). +/// - coordinateConversion: Coordinate-system conversion applied to all transforms. +/// - completion: Called on the main thread when all async mesh work finishes. +public func loadScene( filename: String, withExtension: String, + importCameras: Bool = false, + importLights: Bool = false, + enableBatching batchingEnabled: Bool = false, + enableGeometryStreaming streamingEnabled: Bool = false, coordinateConversion: CoordinateSystemConversion = .autoDetect, completion: ((Bool) -> Void)? = nil ) { - let completionBox = completion.map { BoolCompletionBox(callback: $0) } + let policy: MeshStreamingPolicy = streamingEnabled ? .outOfCore : .auto + + // Capture flags before closures to avoid naming conflicts with same-named + // module-level functions (enableBatching, etc.) inside completion blocks. + let shouldBatch = batchingEnabled + let shouldImportCameras = importCameras + let shouldImportLights = importLights + + // Destroy the previous scene synchronously. loadScene is a full scene + // replacement, so we finalize immediately rather than deferring to the + // render loop. Deferring would leave the completion stuck in environments + // that have no render loop (e.g. unit tests), because finalizePendingDestroys + // is only called from runFrame(). In production the render loop has already + // drained hasPendingDestroys before user code runs, so calling finalize here + // is a cheap no-op on the entity side. + for entity in scene.getAllEntities() { + destroyEntity(entityId: entity) + } + finalizePendingDestroys() + hasPendingDestroys = false - Task { - // Create a temporary entity ID for tracking the scene load - let sceneLoadEntityId = EntityID.max - 1 // Use a special ID for scene loading + // Run camera/light extraction synchronously BEFORE mesh loading. + // The bare MDLAsset parse (no geometry buffers) is cheap even for large + // files, and having the camera/light entities in place immediately ensures + // the render loop is never left without valid entities during the async load. + let found = (shouldImportCameras || shouldImportLights) + ? extractSceneCamerasAndLights( + filename: filename, + withExtension: withExtension, + coordinateConversion: coordinateConversion, + importCameras: shouldImportCameras, + importLights: shouldImportLights + ) + : (foundCamera: false, foundLight: false) - // Mark as loading - await AssetLoadingState.shared.startLoading(entityId: sceneLoadEntityId, filename: filename) + // Create defaults for anything not found in the file. + if !found.foundCamera { + let camera = createEntity() + setEntityName(entityId: camera, name: "Main Camera") + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + } + if !found.foundLight { + let light = createEntity() + setEntityName(entityId: light, name: "Directional Light") + createDirLight(entityId: light) + } - // Get URL - guard let url = LoadingSystem.shared.resourceURL(forResource: filename, withExtension: withExtension, subResource: nil) else { - handleError(.filenameNotFound, filename) - await AssetLoadingState.shared.finishLoading(entityId: sceneLoadEntityId) - completionBox?.call(false) + let rootEntity = createEntity() + setEntityName(entityId: rootEntity, name: filename) + + setEntityMeshAsync( + entityId: rootEntity, + filename: filename, + withExtension: withExtension, + coordinateConversion: coordinateConversion, + streamingPolicy: policy + ) { success in + guard success else { + completion?(false) return } - if url.pathExtension == "dae" { - handleError(.fileTypeNotSupported, url.pathExtension) - await AssetLoadingState.shared.finishLoading(entityId: sceneLoadEntityId) - completionBox?.call(false) - return + if shouldBatch { + enableBatching(true) + generateBatches() } - // Load scene meshes asynchronously - let meshes = await Mesh.loadSceneMeshesAsync( - url: url, - vertexDescriptor: vertexDescriptor.model, - device: renderInfo.device, - coordinateConversion: coordinateConversion - ) { current, total in - guard total > 0 else { return } + completion?(true) + } +} - Task { - await AssetLoadingState.shared.updateProgress(entityId: sceneLoadEntityId, currentMesh: current, totalMeshes: total) +/// 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. +/// Returns whether at least one camera and at least one light were found and created. +@discardableResult +private func extractSceneCamerasAndLights( + filename: String, + withExtension: String, + coordinateConversion: CoordinateSystemConversion, + importCameras: Bool, + importLights: Bool +) -> (foundCamera: Bool, foundLight: Bool) { + guard let url = LoadingSystem.shared.resourceURL( + forResource: filename, + withExtension: withExtension, + subResource: nil + ) else { return (false, false) } + + let asset = MDLAsset(url: url) + + // Reuse the shared orientation helper from Mesh.swift — single source of truth. + let orientationMatrix = orientationTransformForAsset(asset, conversion: coordinateConversion) + + var foundCamera = false + var foundLight = false + + // Light types the engine can represent. Checked before createEntity() so we + // never allocate an entity we immediately have to destroy. + let supportedLightTypes: Set = [ + .directional, .point, .spot, .rectangularArea, .discArea, .linear, .superElliptical, + ] + + for object in asset.childObjects(of: MDLObject.self) { + // ── Cameras ────────────────────────────────────────────────────────── + // First camera found wins; subsequent cameras in the file are ignored. + if importCameras, !foundCamera, let mdlCamera = object as? MDLCamera { + let raw = composedWorldTransform(for: mdlCamera) + let world = orientationMatrix == matrix_identity_float4x4 + ? raw : simd_mul(orientationMatrix, raw) + + let eye = simd_float3(world.columns.3.x, world.columns.3.y, world.columns.3.z) + // Camera local forward is -Z; transform into world space. + let fwd = simd_mul(world, simd_float4(0, 0, -1, 0)) + let upVec = simd_mul(world, simd_float4(0, 1, 0, 0)) + let target = eye + simd_float3(fwd.x, fwd.y, fwd.z) + let up = simd_float3(upVec.x, upVec.y, upVec.z) + + let cameraEntity = createEntity() + setEntityName(entityId: cameraEntity, name: mdlCamera.name.isEmpty ? "Camera" : mdlCamera.name) + createGameCamera(entityId: cameraEntity) + cameraLookAt(entityId: cameraEntity, eye: eye, target: target, up: up) + CameraSystem.shared.activeCamera = cameraEntity + foundCamera = true + + if mdlCamera.name.count > 1 { + Logger.log(message: "[loadScene] Imported camera '\(mdlCamera.name)' from file.") } } - // Cache meshes for streaming system (so reloads don't require disk I/O) - MeshResourceManager.shared.cacheLoadedMeshes(url: url, meshArrays: meshes) + // ── Lights ─────────────────────────────────────────────────────────── + if importLights, let mdlLight = object as? MDLLight { + // Skip unsupported types before allocating an entity. + guard supportedLightTypes.contains(mdlLight.lightType) else { continue } - // Process on main thread - if meshes.isEmpty { - handleError(.assetDataMissing, filename) - await AssetLoadingState.shared.finishLoading(entityId: sceneLoadEntityId) - completionBox?.call(false) - return - } + let lightEntity = createEntity() + setEntityName(entityId: lightEntity, name: mdlLight.name.isEmpty ? "Light" : mdlLight.name) - for mesh in meshes where mesh.count > 0 { - let entityId = createEntity() - - if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { - registerTransformComponent(entityId: entityId) + switch mdlLight.lightType { + case .directional: + createDirLight(entityId: lightEntity) + case .point: + createPointLight(entityId: lightEntity) + case .spot: + createSpotLight(entityId: lightEntity) + if let spot = mdlLight as? MDLPhysicallyPlausibleLight { + updateLightConeAngle(entityId: lightEntity, coneAngle: spot.outerConeAngle) + } + default: + // rectangularArea, discArea, linear, superElliptical + createAreaLight(entityId: lightEntity) } - if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { - registerSceneGraphComponent(entityId: entityId) - } + let raw = composedWorldTransform(for: mdlLight) + let world = orientationMatrix == matrix_identity_float4x4 + ? raw : simd_mul(orientationMatrix, raw) + applyWorldTransform(world, to: lightEntity) - associateMeshesToEntity(entityId: entityId, meshes: mesh) - registerRenderComponent(entityId: entityId, meshes: mesh, url: url, assetName: mesh.first!.assetName) - setEntityName(entityId: entityId, name: mesh.first!.assetName) - setEntitySkeleton(entityId: entityId, filename: filename, withExtension: withExtension) + if let pbrLight = mdlLight as? MDLPhysicallyPlausibleLight, + let cgColor = pbrLight.color, + let comps = cgColor.components, + cgColor.numberOfComponents >= 3 + { + let color = simd_float3(Float(comps[0]), Float(comps[1]), Float(comps[2])) + updateLightColor(entityId: lightEntity, color: color) + updateLightIntensity(entityId: lightEntity, intensity: pbrLight.lumens) + } + foundLight = true } - - await AssetLoadingState.shared.finishLoading(entityId: sceneLoadEntityId) - completionBox?.call(true) } + + return (foundCamera: foundCamera, foundLight: foundLight) } /// Cache to avoid reloading USDZ files multiple times for skeleton checks diff --git a/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift b/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift index 0ad1e106..3c160294 100644 --- a/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift +++ b/Tests/UntoldEngineRenderTests/AsyncMeshLoadingTest.swift @@ -650,4 +650,162 @@ final class AsyncMeshLoadingTest: BaseRenderSetup { XCTAssertTrue(hasComponent(entityId: entityId, componentType: RenderComponent.self), "Should have mesh loaded") } } + + // MARK: - loadScene Tests + + func testLoadScene_completionCalledWithTrueOnSuccess() async { + // Given: A valid USDZ file + let expectation = XCTestExpectation(description: "loadScene completed") + var loadSuccess = false + + // When: Load a scene + loadScene(filename: "ball", withExtension: "usdz") { success in + loadSuccess = success + expectation.fulfill() + } + + // Then: Completion should be called with true + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertTrue(loadSuccess, "loadScene should succeed for a valid USDZ file") + } + + func testLoadScene_rootEntityNamedAfterFilename() async { + // Given: A valid USDZ file + let expectation = XCTestExpectation(description: "loadScene completed") + + // When: Load a scene + loadScene(filename: "ball", withExtension: "usdz") { _ in + expectation.fulfill() + } + + // Then: The root entity should be named after the filename + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertNotNil(findEntity(name: "ball"), "Root entity should be named after the filename") + } + + func testLoadScene_createsDefaultCameraWhenImportCamerasDisabled() async { + // Given: importCameras: false (default) + let expectation = XCTestExpectation(description: "loadScene completed") + + // When: Load without importing cameras + loadScene(filename: "ball", withExtension: "usdz", importCameras: false) { _ in + expectation.fulfill() + } + + // Then: A default camera should be created and set as active + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertNotNil(CameraSystem.shared.activeCamera, "Active camera should be set after loadScene") + XCTAssertNotNil(findEntity(name: "Game Camera"), "Default camera entity should be named 'Game Camera'") + } + + func testLoadScene_createsDefaultLightWhenImportLightsDisabled() async { + // Given: importLights: false (default) + let expectation = XCTestExpectation(description: "loadScene completed") + + // When: Load without importing lights + loadScene(filename: "ball", withExtension: "usdz", importLights: false) { _ in + expectation.fulfill() + } + + // Then: A default directional light entity should be created + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertNotNil(findEntity(name: "Directional Light"), "Default light entity should be named 'Directional Light'") + } + + func testLoadScene_destroysPreviousSceneOnReload() async { + // Given: A scene loaded with one asset + let firstExpectation = XCTestExpectation(description: "First load completed") + loadScene(filename: "ball", withExtension: "usdz") { _ in firstExpectation.fulfill() } + await fulfillment(of: [firstExpectation], timeout: 10.0) + XCTAssertNotNil(findEntity(name: "ball"), "First scene root entity should exist before reload") + + // When: A different scene is loaded + let secondExpectation = XCTestExpectation(description: "Second load completed") + loadScene(filename: "grass", withExtension: "usdz") { _ in secondExpectation.fulfill() } + await fulfillment(of: [secondExpectation], timeout: 30.0) + + // Then: The previous scene's root entity should be gone; the new one should exist + XCTAssertNil(findEntity(name: "ball"), "Previous scene root entity should be destroyed after reload") + XCTAssertNotNil(findEntity(name: "grass"), "New scene root entity should exist after reload") + } + + func testLoadScene_activeCameraRemainsSetAfterReload() async { + // Given: An initial scene is fully loaded + let firstExpectation = XCTestExpectation(description: "First load completed") + loadScene(filename: "ball", withExtension: "usdz") { _ in firstExpectation.fulfill() } + await fulfillment(of: [firstExpectation], timeout: 10.0) + + // When: The scene is reloaded + let secondExpectation = XCTestExpectation(description: "Second load completed") + loadScene(filename: "ball", withExtension: "usdz") { _ in secondExpectation.fulfill() } + await fulfillment(of: [secondExpectation], timeout: 10.0) + + // Then: Active camera should be valid after the reload completes + XCTAssertNotNil(CameraSystem.shared.activeCamera, "Active camera must be set after reload") + } + + func testLoadScene_invalidFilenameCallsCompletionWithFalse() async { + // Given: A filename that doesn't resolve to a real file + let expectation = XCTestExpectation(description: "loadScene completed with failure") + var loadSuccess = true + + // When: Load a non-existent scene + loadScene(filename: "nonexistent_scene_xyz", withExtension: "usdz") { success in + loadSuccess = success + expectation.fulfill() + } + + // Then: Completion should be called with false + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertFalse(loadSuccess, "loadScene should report failure for a non-existent file") + } + + func testLoadScene_invalidFilenameStillProvidesDefaultCameraAndLight() async { + // Given: A filename that doesn't resolve to a real file + let expectation = XCTestExpectation(description: "loadScene completed with failure") + + // When: Load a non-existent scene + loadScene(filename: "nonexistent_scene_xyz", withExtension: "usdz") { _ in + expectation.fulfill() + } + + // Then: Default camera and light should still be created despite the load failure + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertNotNil(CameraSystem.shared.activeCamera, "Default camera should be created even when load fails") + XCTAssertNotNil(findEntity(name: "Directional Light"), "Default light should be created even when load fails") + } + + func testLoadScene_enableBatching_completesSuccessfully() async { + // Given: enableBatching: true + let expectation = XCTestExpectation(description: "loadScene with batching completed") + var loadSuccess = false + + // When: Load a scene with batching enabled + loadScene(filename: "ball", withExtension: "usdz", enableBatching: true) { success in + loadSuccess = success + expectation.fulfill() + } + + // Then: Load should succeed and the root entity should exist + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertTrue(loadSuccess, "loadScene with enableBatching should succeed") + XCTAssertNotNil(findEntity(name: "ball"), "Root entity should exist after a batched load") + } + + func testLoadScene_enableGeometryStreaming_completesSuccessfully() async { + // Given: enableGeometryStreaming: true (forces .outOfCore policy) + let expectation = XCTestExpectation(description: "loadScene with streaming completed") + var loadSuccess = false + + // When: Load a scene with geometry streaming enabled + loadScene(filename: "ball", withExtension: "usdz", enableGeometryStreaming: true) { success in + loadSuccess = success + expectation.fulfill() + } + + // Then: Load should succeed + await fulfillment(of: [expectation], timeout: 10.0) + XCTAssertTrue(loadSuccess, "loadScene with enableGeometryStreaming should succeed") + XCTAssertNotNil(findEntity(name: "ball"), "Root entity should exist after a streaming load") + } } From 716c7455a169bff75d55a35547d5d9e82255a77b Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 25 Mar 2026 00:05:16 -0700 Subject: [PATCH 2/4] [Docs] Updated documents --- README.md | 26 +- .../BuildSystem/BuildTemplates.swift | 290 ++++-------------- docs/API/GettingStarted.md | 128 ++++++++ 3 files changed, 202 insertions(+), 242 deletions(-) create mode 100644 docs/API/GettingStarted.md diff --git a/README.md b/README.md index 403c3d28..20178f14 100644 --- a/README.md +++ b/README.md @@ -126,20 +126,9 @@ Untold Engine aims to support applications such as: --- -# Engine Architecture: - -- [Rendering System](docs/Architecture/renderingSystem.md) -- [XR Rendering System](docs/Architecture/xrRenderingSystem.md) -- [Static Batching System](docs/Architecture/batchingSystem.md) -- [Geometry Streaming System](docs/Architecture/geometryStreamingSystem.md) -- [LOD System](docs/Architecture/lodSystem.md) -- [Progressive Asset Loader](docs/Architecture/progressiveAssetLoader.md) -- [Streaming Cache Lifecycle](docs/Architecture/streamingCacheLifecycle.md) -- [Texture Streaming System](docs/Architecture/textureStreamingSystem.md) -- [Out of Core](docs/Architecture/outOfCore.md) - # Engine API +- [Getting Started](docs/API/GettingStarted.md) - [Registration System](docs/API/UsingRegistrationSystem.md) - [Scenegraph](docs/API/UsingScenegraph.md) - [Transform System](docs/API/UsingTransformSystem.md) @@ -161,6 +150,19 @@ Untold Engine aims to support applications such as: - [Spatical Debugger](docs/API/SpatialDebugger.md) - [Profiler](/docs/API/UsingProfiler.md) + +# Engine Architecture: + +- [Rendering System](docs/Architecture/renderingSystem.md) +- [XR Rendering System](docs/Architecture/xrRenderingSystem.md) +- [Static Batching System](docs/Architecture/batchingSystem.md) +- [Geometry Streaming System](docs/Architecture/geometryStreamingSystem.md) +- [LOD System](docs/Architecture/lodSystem.md) +- [Progressive Asset Loader](docs/Architecture/progressiveAssetLoader.md) +- [Streaming Cache Lifecycle](docs/Architecture/streamingCacheLifecycle.md) +- [Texture Streaming System](docs/Architecture/textureStreamingSystem.md) +- [Out of Core](docs/Architecture/outOfCore.md) + --- # Set Up an Xcode Project with Untold Engine diff --git a/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift b/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift index 770fc00e..cb52b52d 100644 --- a/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift +++ b/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift @@ -118,49 +118,26 @@ import Foundation // Configure game Systems configureEngineSystems() - setSceneReady(false) - - // load a mesh - let entity = createEntity() - - // city.usdz must be inside the GameData/model folder (generated path: GameData/Models) - setEntityMeshAsync(entityId: entity, filename: "city", withExtension: "usdz"){_ in - - translateBy(entityId: entity, position: simd_float3(0.0,-10.0,0.0)) - - // Enable Geometry Streaming - enableStreaming( - entityId: entity, - streamingRadius: 40.0, - unloadRadius: 60.0, - priority: 10 - ) - - // Enable Static Batching - setEntityStaticBatch(entityId: entity) - enableBatching(true) - generateBatches() - - setSceneReady(true) + // Load a USDZ scene. + // city.usdz must be inside the GameData/Models folder. + // loadScene() replaces the current world, creates a default camera and + // directional light, and delegates to setEntityMeshAsync() internally. + // Streaming defaults: streamingRadius=100, unloadRadius=150, priority=10. + // To override, call enableStreaming(entityId:streamingRadius:unloadRadius:priority:) + // on the root entity inside the completion block. + // Remove enableBatching / enableGeometryStreaming if not needed. + loadScene( + filename: "city", + withExtension: "usdz", + enableBatching: true, + enableGeometryStreaming: true + ) { success in + setSceneReady(success) } } // MARK: - Setup Methods - /// Load and play the first available scene - private func loadAndPlayFirstScene(sceneName: String? = nil) { - setSceneReady(false) - if let sceneURL = findFirstScene(name: sceneName) { - playSceneAt(url: sceneURL) { - setSceneReady(true) - Logger.log(message: "✅ Scene loaded: \\(sceneURL.lastPathComponent)") - } - } else { - setSceneReady(true) - Logger.log(message: "⚠️ No scene files found") - } - } - /// Configure game Systems for play mode private func configureEngineSystems() { gameMode = true @@ -251,44 +228,6 @@ import Foundation } } - /// Find a scene in GameData/Scenes by name, or the first JSON scene if no name is provided. - func findFirstScene(name: String? = nil) -> URL? { - guard let gameDataURL = Bundle.main.url(forResource: "GameData", withExtension: nil) else { - Logger.log(message: "⚠️ GameData directory not found") - return nil - } - - let scenesURL = gameDataURL.appendingPathComponent("Scenes") - - guard let sceneFiles = try? FileManager.default.contentsOfDirectory( - at: scenesURL, - includingPropertiesForKeys: nil - ) else { - return nil - } - - let jsonSceneFiles = sceneFiles.filter { $0.pathExtension.lowercased() == "json" } - - guard let name = name, name.isEmpty == false else { - return jsonSceneFiles.first - } - - let sceneFileName = name.hasSuffix(".json") ? name : "\\(name).json" - return jsonSceneFiles.first { - $0.lastPathComponent.caseInsensitiveCompare(sceneFileName) == .orderedSame - } - } - - /// Load all USC scripts from GameData/Scripts - func loadBundledScripts() { - guard let gameDataURL = Bundle.main.url(forResource: "GameData", withExtension: nil) else { - return - } - - let scriptsURL = gameDataURL.appendingPathComponent("Scripts") - let count = loadScripts(from: scriptsURL) - Logger.log(message: "✅ Loaded \\(count) script(s) from bundle") - } } """ @@ -665,49 +604,26 @@ import Foundation // Configure game Systems configureEngineSystems() - setSceneReady(false) - - // load a mesh - let entity = createEntity() - - // city.usdz must be inside the GameData/model folder (generated path: GameData/Models) - setEntityMeshAsync(entityId: entity, filename: "city", withExtension: "usdz"){_ in - - translateBy(entityId: entity, position: simd_float3(0.0,-10.0,0.0)) - - // Enable Geometry Streaming - enableStreaming( - entityId: entity, - streamingRadius: 40.0, - unloadRadius: 60.0, - priority: 10 - ) - - // Enable Static Batching - setEntityStaticBatch(entityId: entity) - enableBatching(true) - generateBatches() - - setSceneReady(true) + // Load a USDZ scene. + // city.usdz must be inside the GameData/Models folder. + // loadScene() replaces the current world, creates a default camera and + // directional light, and delegates to setEntityMeshAsync() internally. + // Streaming defaults: streamingRadius=100, unloadRadius=150, priority=10. + // To override, call enableStreaming(entityId:streamingRadius:unloadRadius:priority:) + // on the root entity inside the completion block. + // Remove enableBatching / enableGeometryStreaming if not needed. + loadScene( + filename: "city", + withExtension: "usdz", + enableBatching: true, + enableGeometryStreaming: true + ) { success in + setSceneReady(success) } } // MARK: - Setup Methods - /// Load and play the first available scene - private func loadAndPlayFirstScene(sceneName: String? = nil) { - setSceneReady(false) - if let sceneURL = findFirstScene(name: sceneName) { - playSceneAt(url: sceneURL) { - setSceneReady(true) - Logger.log(message: "✅ Scene loaded: \\(sceneURL.lastPathComponent)") - } - } else { - setSceneReady(true) - Logger.log(message: "⚠️ No scene files found") - } - } - /// Configure game Systems for play mode private func configureEngineSystems() { gameMode = true @@ -838,49 +754,26 @@ import Foundation // Configure game Systems configureEngineSystems() - setSceneReady(false) - - // load a mesh - let entity = createEntity() - - // city.usdz must be inside the GameData/model folder (generated path: GameData/Models) - setEntityMeshAsync(entityId: entity, filename: "city", withExtension: "usdz"){_ in - - translateBy(entityId: entity, position: simd_float3(0.0,-10.0,0.0)) - - // Enable Geometry Streaming - enableStreaming( - entityId: entity, - streamingRadius: 40.0, - unloadRadius: 60.0, - priority: 10 - ) - - // Enable Static Batching - setEntityStaticBatch(entityId: entity) - enableBatching(true) - generateBatches() - - setSceneReady(true) + // Load a USDZ scene. + // city.usdz must be inside the GameData/Models folder. + // loadScene() replaces the current world, creates a default camera and + // directional light, and delegates to setEntityMeshAsync() internally. + // Streaming defaults: streamingRadius=100, unloadRadius=150, priority=10. + // To override, call enableStreaming(entityId:streamingRadius:unloadRadius:priority:) + // on the root entity inside the completion block. + // Remove enableBatching / enableGeometryStreaming if not needed. + loadScene( + filename: "city", + withExtension: "usdz", + enableBatching: true, + enableGeometryStreaming: true + ) { success in + setSceneReady(success) } } // MARK: - Setup Methods - /// Load and play the first available scene - private func loadAndPlayFirstScene(sceneName: String? = nil) { - setSceneReady(false) - if let sceneURL = findFirstScene(name: sceneName) { - playSceneAt(url: sceneURL) { - setSceneReady(true) - Logger.log(message: "✅ Scene loaded: \\(sceneURL.lastPathComponent)") - } - } else { - setSceneReady(true) - Logger.log(message: "⚠️ No scene files found") - } - } - /// Configure game Systems for play mode private func configureEngineSystems() { gameMode = true @@ -1195,51 +1088,26 @@ import Foundation // Configure game Systems configureEngineSystems() - setSceneReady(false) - - // load a scene - let entity = createEntity() - - // city.usdz must be inside the GameData/model folder (generated path: GameData/Models) - setEntityMeshAsync(entityId: entity, filename: "city", withExtension: "usdz"){_ in - - translateBy(entityId: entity, position: simd_float3(0.0,-10.0,0.0)) - - // Enable Geometry Streaming - enableStreaming( - entityId: entity, - streamingRadius: 40.0, - unloadRadius: 60.0, - priority: 10 - ) - - // Enable Static Batching - setEntityStaticBatch(entityId: entity) - enableBatching(true) - generateBatches() - - setSceneReady(true) + // Load a USDZ scene. + // city.usdz must be inside the GameData/Models folder. + // loadScene() replaces the current world, creates a default camera and + // directional light, and delegates to setEntityMeshAsync() internally. + // Streaming defaults: streamingRadius=100, unloadRadius=150, priority=10. + // To override, call enableStreaming(entityId:streamingRadius:unloadRadius:priority:) + // on the root entity inside the completion block. + // Remove enableBatching / enableGeometryStreaming if not needed. + loadScene( + filename: "city", + withExtension: "usdz", + enableBatching: true, + enableGeometryStreaming: true + ) { success in + setSceneReady(success) } } // MARK: - Setup Methods - /// Load and play the first available scene - private func loadAndPlayFirstScene(sceneName: String? = nil) { - setSceneReady(false) - if let sceneURL = findFirstScene(name: sceneName) { - Logger.log(message: "✅ Found scene: \\(sceneURL.lastPathComponent)") - playSceneAt(url: sceneURL) { - setSceneReady(true) - Logger.log(message: "✅ Scene loaded: \\(sceneURL.lastPathComponent)") - } - } else { - setSceneReady(true) - Logger.log(message: "⚠️ No scene files found - nothing will render") - Logger.log(message: "⚠️ Create a scene in the editor and rebuild the project") - } - } - /// Configure game Systems for play mode private func configureEngineSystems() { gameMode = true @@ -1391,44 +1259,6 @@ import Foundation } } - /// Find a scene in GameData/Scenes by name, or the first JSON scene if no name is provided. - func findFirstScene(name: String? = nil) -> URL? { - guard let gameDataURL = Bundle.main.url(forResource: "GameData", withExtension: nil) else { - Logger.log(message: "⚠️ GameData directory not found") - return nil - } - - let scenesURL = gameDataURL.appendingPathComponent("Scenes") - - guard let sceneFiles = try? FileManager.default.contentsOfDirectory( - at: scenesURL, - includingPropertiesForKeys: nil - ) else { - return nil - } - - let jsonSceneFiles = sceneFiles.filter { $0.pathExtension.lowercased() == "json" } - - guard let name = name, name.isEmpty == false else { - return jsonSceneFiles.first - } - - let sceneFileName = name.hasSuffix(".json") ? name : "\\(name).json" - return jsonSceneFiles.first { - $0.lastPathComponent.caseInsensitiveCompare(sceneFileName) == .orderedSame - } - } - - /// Load all USC scripts from GameData/Scripts - func loadBundledScripts() { - guard let gameDataURL = Bundle.main.url(forResource: "GameData", withExtension: nil) else { - return - } - - let scriptsURL = gameDataURL.appendingPathComponent("Scripts") - let count = loadScripts(from: scriptsURL) - Logger.log(message: "✅ Loaded \\(count) script(s) from bundle") - } } """ diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md new file mode 100644 index 00000000..e17b8326 --- /dev/null +++ b/docs/API/GettingStarted.md @@ -0,0 +1,128 @@ +--- +id: gettingstarted +title: Getting Started +sidebar_position: 1 +--- + +# Getting Started + +This guide walks through a typical `GameScene` setup: loading a USDZ scene, +adding entities, configuring animation and physics, and placing the camera. + +--- + +## Loading a Scene + +`loadScene()` is the primary entry point for loading a USDZ world. It replaces +the current scene, creates a default camera and directional light, and handles +async mesh loading internally. You do not need to create entities or manage +cameras manually. + +```swift +loadScene( + filename: "stadium", + withExtension: "usdz", + enableBatching: true, + enableGeometryStreaming: true +) { success in + setSceneReady(success) +} +``` + +| Parameter | What it does | +|---|---| +| `enableBatching` | Merges static meshes into draw call batches after loading. | +| `enableGeometryStreaming` | Streams geometry in/out of core memory based on camera proximity. Defaults: `streamingRadius=100`, `unloadRadius=150`. To override, call `enableStreaming(entityId:streamingRadius:unloadRadius:priority:)` on the root entity inside the completion block. | + +After `loadScene` completes, call `setSceneReady(true)` (or pass `success` +directly) to signal that input and game logic can safely run. + +--- + +## Finding Entities in the Loaded Scene + +If your USDZ file contains a named mesh (e.g. a player character), retrieve it +with `findEntity(name:)` inside the completion block or after `setSceneReady`. + +```swift +if let player = findEntity(name: "player") { + rotateTo(entityId: player, angle: 0, axis: simd_float3(0.0, 1.0, 0.0)) + + // Attach animation clips + setEntityAnimations(entityId: player, filename: "running", withExtension: "usdz", name: "running") + setEntityAnimations(entityId: player, filename: "idle", withExtension: "usdz", name: "idle") + + // Enable physics + setEntityKinetics(entityId: player) +} +``` + +--- + +## Adding Entities to the Scene + +Use `createEntity()` + `setEntityMesh()` to add props and objects that are not +embedded in the loaded USDZ scene. + +```swift +let ball = createEntity() +setEntityMesh(entityId: ball, filename: "ball", withExtension: "usdz") +setEntityName(entityId: ball, name: "ball") +translateBy(entityId: ball, position: simd_float3(0.0, 0.5, 3.0)) +setEntityKinetics(entityId: ball) +``` + +`setEntityMesh` is synchronous and suited for small, single-mesh assets. For +large or multi-mesh assets use `setEntityMeshAsync` instead (see +[Async Loading System](asyncloadingsystem)). + +--- + +## Camera and Lighting + +`loadScene` always provides a default camera and directional light. Move the +camera after the scene loads, or configure lighting intensity directly: + +```swift +moveCameraTo(entityId: findGameCamera(), 0.0, 3.0, 10.0) +ambientIntensity = 0.4 +``` + +--- + +## Putting It All Together + +A complete `GameScene.init()` using the patterns above: + +```swift +init() { + setupAssetPaths() + configureEngineSystems() + + loadScene( + filename: "stadium", + withExtension: "usdz", + enableBatching: true, + enableGeometryStreaming: true + ) { success in + + if let player = findEntity(name: "player") { + rotateTo(entityId: player, angle: 0, axis: simd_float3(0.0, 1.0, 0.0)) + setEntityAnimations(entityId: player, filename: "running", withExtension: "usdz", name: "running") + setEntityAnimations(entityId: player, filename: "idle", withExtension: "usdz", name: "idle") + setEntityKinetics(entityId: player) + } + + let ball = createEntity() + setEntityMesh(entityId: ball, filename: "ball", withExtension: "usdz") + setEntityName(entityId: ball, name: "ball") + translateBy(entityId: ball, position: simd_float3(0.0, 0.5, 3.0)) + setEntityKinetics(entityId: ball) + + moveCameraTo(entityId: findGameCamera(), 0.0, 3.0, 10.0) + ambientIntensity = 0.4 + + setSceneReady(success) + } +} +``` From 136bde368c5a34c965db7221d3b3fc940041de8d Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 25 Mar 2026 00:20:17 -0700 Subject: [PATCH 3/4] [Docs] Updated readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 20178f14..59da041c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ Untold Engine is designed for developers who: This is not a drag-and-drop editor-first engine — it is a **code-driven engine for developers who want to understand and shape the system**. +Check out these videos to see the engine capabilities using the Vision Pro + +[Cartoon City Rendering](https://vimeo.com/1176823067?share=copy&fl=sv&fe=ci) + +[Game Dungeon](https://vimeo.com/1176823994?share=copy&fl=sv&fe=ci) Creator & Lead Developer: http://www.haroldserrano.com From 1ac3a7b7db313a0325638f6785d9e2fc1ffa81fe Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 25 Mar 2026 00:33:18 -0700 Subject: [PATCH 4/4] [Test] Fixed build system test --- Tests/UntoldEngineTests/BuildSystemTests.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Tests/UntoldEngineTests/BuildSystemTests.swift b/Tests/UntoldEngineTests/BuildSystemTests.swift index 58501e14..fe9f70a5 100644 --- a/Tests/UntoldEngineTests/BuildSystemTests.swift +++ b/Tests/UntoldEngineTests/BuildSystemTests.swift @@ -223,8 +223,6 @@ final class BuildSystemTests: XCTestCase { "GameScene.swift should import simd") XCTAssertTrue(gameSceneContent.contains("setupAssetPaths()"), "GameScene.swift should call setupAssetPaths in init") - XCTAssertTrue(gameSceneContent.contains("private func loadAndPlayFirstScene"), - "GameScene.swift should define loadAndPlayFirstScene helper") XCTAssertFalse(gameSceneContent.contains("func setupAssetPaths()"), "GameScene.swift should keep asset helpers in GameSceneUtils.swift") XCTAssertTrue(gameSceneContent.contains("func update(deltaTime"), @@ -247,10 +245,6 @@ final class BuildSystemTests: XCTestCase { "GameSceneUtils.swift should contain setupAssetPaths") XCTAssertTrue(gameSceneUtilsContent.contains("assetBasePath"), "GameSceneUtils.swift should set assetBasePath") - XCTAssertTrue(gameSceneUtilsContent.contains("func findFirstScene"), - "GameSceneUtils.swift should contain findFirstScene") - XCTAssertTrue(gameSceneUtilsContent.contains("func loadBundledScripts()"), - "GameSceneUtils.swift should contain loadBundledScripts") } func testMacOSGameViewControllerSwiftGeneratedWithExpectedContent() { @@ -480,8 +474,6 @@ final class BuildSystemTests: XCTestCase { "iOS GameScene.swift should import simd") XCTAssertTrue(gameSceneContent.contains("setupAssetPaths()"), "iOS GameScene.swift should call setupAssetPaths in init") - XCTAssertTrue(gameSceneContent.contains("private func loadAndPlayFirstScene"), - "iOS GameScene.swift should define loadAndPlayFirstScene helper") XCTAssertFalse(gameSceneContent.contains("func setupAssetPaths()"), "iOS GameScene.swift should keep asset helpers in GameSceneUtils.swift") XCTAssertTrue(gameSceneContent.contains("func update(deltaTime"), @@ -504,10 +496,6 @@ final class BuildSystemTests: XCTestCase { "iOS GameSceneUtils.swift should contain setupAssetPaths") XCTAssertTrue(gameSceneUtilsContent.contains("assetBasePath"), "iOS GameSceneUtils.swift should set assetBasePath") - XCTAssertTrue(gameSceneUtilsContent.contains("func findFirstScene"), - "iOS GameSceneUtils.swift should contain findFirstScene") - XCTAssertTrue(gameSceneUtilsContent.contains("func loadBundledScripts()"), - "iOS GameSceneUtils.swift should contain loadBundledScripts") } func testIOSGameViewControllerSwiftGeneratedWithExpectedContent() { @@ -775,8 +763,6 @@ final class BuildSystemTests: XCTestCase { "iOS AR GameSceneUtils.swift should extend GameScene") XCTAssertTrue(gameSceneUtilsContent.contains("func setupAssetPaths()"), "iOS AR GameSceneUtils.swift should contain setupAssetPaths") - XCTAssertTrue(gameSceneUtilsContent.contains("func loadBundledScripts()"), - "iOS AR GameSceneUtils.swift should contain loadBundledScripts") } // MARK: - visionOS Template Tests @@ -963,10 +949,6 @@ final class BuildSystemTests: XCTestCase { "visionOS GameSceneUtils.swift should include setupAssetPaths") XCTAssertTrue(gameSceneUtilsContent.contains("func listDirectoryRecursively"), "visionOS GameSceneUtils.swift should include directory listing helper") - XCTAssertTrue(gameSceneUtilsContent.contains("func findFirstScene"), - "visionOS GameSceneUtils.swift should include findFirstScene") - XCTAssertTrue(gameSceneUtilsContent.contains("func loadBundledScripts()"), - "visionOS GameSceneUtils.swift should include loadBundledScripts") } func testIOSARSinglePlatformUsesUntoldEngineAROnly() throws {