diff --git a/README.md b/README.md index d7d01b0e..010c6d92 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ http://www.haroldserrano.com The fastest way to experience Untold Engine is to run the demo project. +> **Recommendation:** Use the latest stable release instead of the `develop` +> branch. The `develop` branch is the bleeding-edge version of Untold Engine and +> is updated frequently, so it may contain unstable changes or regressions. + Clone the repository and launch the demo: ```bash diff --git a/Sources/DemoGame/DemoHUD.swift b/Sources/DemoGame/DemoHUD.swift index c8cdfa43..8597b9b9 100644 --- a/Sources/DemoGame/DemoHUD.swift +++ b/Sources/DemoGame/DemoHUD.swift @@ -126,7 +126,8 @@ sectionLabel("CONTROLS") controlHint("WASD / QE", "Translate") - controlHint("Right-click (shift) drag", "Orbit/Rotate") + controlHint("Right-drag (+ Shift)", "Orbit / Yaw") + controlHint("Two-finger swipe (+ Shift)", "Orbit / Yaw") Divider() diff --git a/Sources/DemoGame/GameScene.swift b/Sources/DemoGame/GameScene.swift index a38b6f2e..cb01cd87 100644 --- a/Sources/DemoGame/GameScene.swift +++ b/Sources/DemoGame/GameScene.swift @@ -49,6 +49,7 @@ private var loadedContent: LoadedContent = .none private var cameraBehavior: CameraBehavior = .flyOrbit private var wasRightMousePressed: Bool = false + private var wasScrolling: Bool = false init() { InputSystem.shared.registerKeyboardEvents() @@ -333,6 +334,25 @@ } } wasRightMousePressed = input.keyState.rightMousePressed + + // Two-finger trackpad drag fires scrollWheel events — support it as an + // alternative orbit (and shift+scroll for yaw) so users don't need + // right-click drag. resetOrbitTarget fires only on the first scroll + // frame, matching the right-mouse-press behaviour above. + let scroll = input.scrollDelta + let isScrolling = (scroll.x != 0 || scroll.y != 0) && !input.keyState.rightMousePressed + if isScrolling { + if !wasScrolling { + resetOrbitTarget(entityId: camera) + } + if input.keyState.shiftPressed { + rotateCamera(entityId: camera, pitch: 0, yaw: scroll.x, sensitivity: -0.01) + } else { + orbitCameraAround(entityId: camera, uDelta: scroll) + } + } + wasScrolling = isScrolling + input.scrollDelta = .zero } private func resetOrbitTarget(entityId: EntityID) { diff --git a/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift b/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift index c596ce11..1bed2d09 100644 --- a/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift +++ b/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift @@ -182,53 +182,6 @@ import Foundation // Add your custom input handling here } - // MARK: - Scene Loading - - /// Load a `.untoldscene` from `GameData/Scenes`. - /// - /// The scene file is JSON-backed and contains project-relative references to - /// `.untold`, stream-model manifests, animations, HDR files, and other scene assets. - /// `assetBasePath` must already point at `GameData`. - func loadUntoldScene( - named sceneName: String, - meshLoadingMode: MeshLoadingMode = .asyncDefault, - completion: ((Bool) -> Void)? = nil - ) { - guard let gameDataURL = assetBasePath else { - Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.") - completion?(false) - return - } - - let sceneURL = gameDataURL - .appendingPathComponent("Scenes", isDirectory: true) - .appendingPathComponent(sceneName) - .appendingPathExtension("untoldscene") - - guard FileManager.default.fileExists(atPath: sceneURL.path) else { - Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)") - completion?(false) - return - } - - do { - let data = try Data(contentsOf: sceneURL) - let sceneData = try JSONDecoder().decode(SceneData.self, from: data) - - // Ensure all relative asset references resolve against the bundled GameData. - assetBasePath = gameDataURL - - Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)") - - deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) { - Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)") - completion?(true) - } - } catch { - Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)") - completion?(false) - } - } } """ @@ -673,53 +626,6 @@ import Foundation // Add your custom input handling here } - // MARK: - Scene Loading - - /// Load a `.untoldscene` from `GameData/Scenes`. - /// - /// The scene file is JSON-backed and contains project-relative references to - /// `.untold`, stream-model manifests, animations, HDR files, and other scene assets. - /// `assetBasePath` must already point at `GameData`. - func loadUntoldScene( - named sceneName: String, - meshLoadingMode: MeshLoadingMode = .asyncDefault, - completion: ((Bool) -> Void)? = nil - ) { - guard let gameDataURL = assetBasePath else { - Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.") - completion?(false) - return - } - - let sceneURL = gameDataURL - .appendingPathComponent("Scenes", isDirectory: true) - .appendingPathComponent(sceneName) - .appendingPathExtension("untoldscene") - - guard FileManager.default.fileExists(atPath: sceneURL.path) else { - Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)") - completion?(false) - return - } - - do { - let data = try Data(contentsOf: sceneURL) - let sceneData = try JSONDecoder().decode(SceneData.self, from: data) - - // Ensure all relative asset references resolve against the bundled GameData. - assetBasePath = gameDataURL - - Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)") - - deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) { - Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)") - completion?(true) - } - } catch { - Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)") - completion?(false) - } - } } """ @@ -860,53 +766,6 @@ import Foundation // Add your custom input handling here } - // MARK: - Scene Loading - - /// Load a `.untoldscene` from `GameData/Scenes`. - /// - /// The scene file is JSON-backed and contains project-relative references to - /// `.untold`, stream-model manifests, animations, HDR files, and other scene assets. - /// `assetBasePath` must already point at `GameData`. - func loadUntoldScene( - named sceneName: String, - meshLoadingMode: MeshLoadingMode = .asyncDefault, - completion: ((Bool) -> Void)? = nil - ) { - guard let gameDataURL = assetBasePath else { - Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.") - completion?(false) - return - } - - let sceneURL = gameDataURL - .appendingPathComponent("Scenes", isDirectory: true) - .appendingPathComponent(sceneName) - .appendingPathExtension("untoldscene") - - guard FileManager.default.fileExists(atPath: sceneURL.path) else { - Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)") - completion?(false) - return - } - - do { - let data = try Data(contentsOf: sceneURL) - let sceneData = try JSONDecoder().decode(SceneData.self, from: data) - - // Ensure all relative asset references resolve against the bundled GameData. - assetBasePath = gameDataURL - - Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)") - - deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) { - Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)") - completion?(true) - } - } catch { - Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)") - completion?(false) - } - } } """ @@ -1251,53 +1110,6 @@ import Foundation // Add your custom input handling here } - // MARK: - Scene Loading - - /// Load a `.untoldscene` from `GameData/Scenes`. - /// - /// The scene file is JSON-backed and contains project-relative references to - /// `.untold`, stream-model manifests, animations, HDR files, and other scene assets. - /// `assetBasePath` must already point at `GameData`. - func loadUntoldScene( - named sceneName: String, - meshLoadingMode: MeshLoadingMode = .asyncDefault, - completion: ((Bool) -> Void)? = nil - ) { - guard let gameDataURL = assetBasePath else { - Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.") - completion?(false) - return - } - - let sceneURL = gameDataURL - .appendingPathComponent("Scenes", isDirectory: true) - .appendingPathComponent(sceneName) - .appendingPathExtension("untoldscene") - - guard FileManager.default.fileExists(atPath: sceneURL.path) else { - Logger.log(message: "❌ Scene file not found: \\(sceneURL.path)") - completion?(false) - return - } - - do { - let data = try Data(contentsOf: sceneURL) - let sceneData = try JSONDecoder().decode(SceneData.self, from: data) - - // Ensure all relative asset references resolve against the bundled GameData. - assetBasePath = gameDataURL - - Logger.log(message: "📄 Loading Untold scene: \\(sceneURL.lastPathComponent)") - - deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) { - Logger.log(message: "✅ Finished loading scene: \\(sceneURL.lastPathComponent)") - completion?(true) - } - } catch { - Logger.log(message: "❌ Failed to load scene \\(sceneURL.lastPathComponent): \\(error.localizedDescription)") - completion?(false) - } - } } @MainActor diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 2ae24ddf..914becc2 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -35,8 +35,9 @@ enum SceneAssetKind: String, Codable { struct SceneAssetReference: Codable { var kind: SceneAssetKind - /// Project-relative path from the asset base folder, such as - /// "Models/Robot/Robot.untold" or "StreamModels/City/City.json". + /// Asset path. For local assets this is a project-relative path from the asset base folder + /// (e.g. "Models/Robot/Robot.untold"). For remote stream models this is the full + /// https:// URL string (e.g. "https://cdn.example.com/dungeon/dungeon.json"). var path: String var displayName: String? = nil } @@ -266,6 +267,11 @@ private func sceneAssetReference(kind: SceneAssetKind, url: URL, displayName: St return SceneAssetReference(kind: .procedural, path: url.path, displayName: displayName) } + // Remote URLs are stored as their full https:// string so they survive round-trips. + if url.scheme?.lowercased() == "https" { + return SceneAssetReference(kind: kind, path: url.absoluteString, displayName: displayName) + } + guard let relativePath = projectRelativeAssetPath(for: url) else { Logger.logWarning(message: "[SceneSerializer] Skipping non-project asset reference: \(url.path)") return nil @@ -279,6 +285,11 @@ private func resolvedSceneAssetURL(_ reference: SceneAssetReference) -> URL? { return URL(fileURLWithPath: reference.path) } + // Remote URLs were stored as full https:// strings — return them directly. + if reference.path.hasPrefix("https://") { + return URL(string: reference.path) + } + guard let basePath = assetBasePath else { Logger.logWarning(message: "[SceneSerializer] Cannot resolve asset '\(reference.path)' because assetBasePath is not set") return nil @@ -899,7 +910,7 @@ public func loadGameScene(from url: URL) -> SceneData? { } } -public enum MeshLoadingMode { +public enum MeshLoadingMode: Sendable { case asyncDefault case sync } @@ -1568,6 +1579,71 @@ public func deserializeScene( loadTracker.finishRegistration() } +// MARK: - Scene Loading + +private let untoldSceneFileExtension = "untoldscene" + +private func normalizedUntoldSceneBaseName(_ sceneName: String) -> String? { + let trimmedName = sceneName.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedName.isEmpty == false else { + return nil + } + + let sceneURL = URL(fileURLWithPath: trimmedName) + let fileExtension = sceneURL.pathExtension.lowercased() + guard fileExtension.isEmpty || fileExtension == untoldSceneFileExtension else { + return nil + } + + return sceneURL.deletingPathExtension().lastPathComponent +} + +public func loadUntoldScene( + named sceneName: String, + meshLoadingMode: MeshLoadingMode = .asyncDefault, + completion: ((Bool) -> Void)? = nil +) { + guard let sceneBaseName = normalizedUntoldSceneBaseName(sceneName) else { + Logger.log(message: "❌ loadUntoldScene(named:) only accepts .\(untoldSceneFileExtension) scene files or names without an extension. Received: \(sceneName)") + completion?(false) + return + } + + guard let gameDataURL = assetBasePath else { + Logger.log(message: "❌ assetBasePath is not configured. Call setupAssetPaths() first.") + completion?(false) + return + } + + let sceneURL = gameDataURL + .appendingPathComponent("Scenes", isDirectory: true) + .appendingPathComponent(sceneBaseName) + .appendingPathExtension(untoldSceneFileExtension) + + guard FileManager.default.fileExists(atPath: sceneURL.path) else { + Logger.log(message: "❌ Scene file not found: \(sceneURL.path)") + completion?(false) + return + } + + do { + let data = try Data(contentsOf: sceneURL) + let sceneData = try JSONDecoder().decode(SceneData.self, from: data) + + assetBasePath = gameDataURL + + Logger.log(message: "📄 Loading Untold scene: \(sceneURL.lastPathComponent)") + + deserializeScene(sceneData: sceneData, meshLoadingMode: meshLoadingMode) { + Logger.log(message: "✅ Finished loading scene: \(sceneURL.lastPathComponent)") + completion?(true) + } + } catch { + Logger.log(message: "❌ Failed to load scene \(sceneURL.lastPathComponent): \(error.localizedDescription)") + completion?(false) + } +} + /// Notification posted when asset instance has finished loading and overrides have been applied public extension Notification.Name { static let assetInstanceDidLoad = Notification.Name("assetInstanceDidLoad") diff --git a/Sources/UntoldEngine/Systems/InputSystem+Mouse.swift b/Sources/UntoldEngine/Systems/InputSystem+Mouse.swift index 5d0bf94e..fd2d7786 100644 --- a/Sources/UntoldEngine/Systems/InputSystem+Mouse.swift +++ b/Sources/UntoldEngine/Systems/InputSystem+Mouse.swift @@ -144,13 +144,18 @@ public extension InputSystem { mouseX = Float(location.x) mouseY = Float(location.y) - mouseDeltaX = mouseX - lastMouseX - mouseDeltaY = mouseY - lastMouseY + // event.deltaX/Y gives device-independent per-event movement, avoiding + // the large position jumps that trackpad fast-swipes produce. + mouseDeltaX = Float(event.deltaX) + mouseDeltaY = Float(event.deltaY) } private func handleScrollWheel(_ event: NSEvent) { - scrollDelta.x = Float(event.scrollingDeltaX) - scrollDelta.y = Float(event.scrollingDeltaY) + // Scale trackpad (hasPreciseScrollingDeltas) to mouse-wheel magnitude so + // a two-finger drag orbits at a comparable speed to right-click drag. + let scale: Float = event.hasPreciseScrollingDeltas ? 0.2 : 1.0 + scrollDelta.x = Float(event.scrollingDeltaX) * scale + scrollDelta.y = Float(event.scrollingDeltaY) * scale } #endif } diff --git a/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift b/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift new file mode 100644 index 00000000..fcd4ea8d --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RemoteStreamFlyThroughTests.swift @@ -0,0 +1,303 @@ +// +// RemoteStreamFlyThroughTests.swift +// UntoldEngine +// +// Integration test: loads a remote tiled scene, flies the camera through +// a sequence of waypoints, and PSNR-compares a screenshot at each stop. +// +// ## Workflow +// +// STEP 1 — Generate reference images (first run only): +// 1. Set your real manifest URL in `manifestURLString` below +// (or pass it via UNTOLD_STREAM_MANIFEST_URL env var). +// 2. Adjust `waypoints` to positions that give meaningful views of your scene. +// 3. Uncomment `testGenerateFlythroughReferenceImages` and run it once. +// 4. Copy the PNGs from ~/Downloads/UntoldEngineRenderingTest/ into +// Tests/UntoldEngineRenderTests/Resources/ and add them to the +// test bundle target. +// 5. Re-comment the generator test. +// +// STEP 2 — Run the PSNR regression test: +// Run `testRemoteStreamFlythrough_psnr` normally. It skips automatically +// when UNTOLD_STREAM_MANIFEST_URL is unset and manifestURLString is still +// the placeholder value. +// +// ## Environment variables +// +// UNTOLD_STREAM_MANIFEST_URL — override the CDN manifest URL at runtime +// UNTOLD_PSNR_THRESHOLD — PSNR pass threshold in dB (default 11.0) +// UNTOLD_PYTHON — path to python3 (default "python3") +// + +import CShaderTypes +import Foundation +import simd +import UniformTypeIdentifiers +@preconcurrency @testable import UntoldEngine +import XCTest + +@MainActor +final class RemoteStreamFlyThroughTests: BaseRenderSetup { + // ------------------------------------------------------------------------- + // MARK: - Configuration — edit these for your scene + + // ------------------------------------------------------------------------- + + /// Remote (or local file://) URL of your tile manifest. + /// Override at runtime with the UNTOLD_STREAM_MANIFEST_URL env var. + private let manifestURLString = "https://d8pyi1c08k1w.cloudfront.net/city/city.json" + + /// Waypoints the camera visits. At each stop a screenshot is taken. + /// Adjust positions and look-at targets to match your dungeon layout. + private let waypoints: [CameraWaypoint] = [ + CameraWaypoint( + position: simd_float3(0.0, 13.034016, 1.9943304), + lookAt: simd_float3(0.0, 11.390026, -7.869609), + up: simd_float3(0.0, 1.0, 0.0), + segmentDuration: 6.0 // simulated travel time to NEXT waypoint + ), + CameraWaypoint( + position: simd_float3(41.899933, 17.116201, -9.373858), + lookAt: simd_float3(41.899933, 12.013494, -17.973995), + up: simd_float3(0.0, 1.0, 0.0), + segmentDuration: 6.0 + ), + CameraWaypoint( + position: simd_float3(50.16962, 14.7179165, -5.8297567), + lookAt: simd_float3(42.4472, 9.615206, -2.044711), + up: simd_float3(0.0, 1.0, 0.0), + segmentDuration: 0.0 // terminal waypoint — duration unused + ), + ] + + /// Names used for reference and test PNG filenames. + /// Must match the Resources/*.png names you add to the test bundle. + private let keyframeNames = [ + "FlythroughWaypoint1", + "FlythroughWaypoint2", + "FlythroughWaypoint3", + ] + + // ------------------------------------------------------------------------- + // MARK: - XCTest lifecycle + + // ------------------------------------------------------------------------- + + override func setUp() async throws { + try await super.setUp() + GeometryStreamingSystem.shared.reset() + GeometryStreamingSystem.shared.enabled = true + GeometryStreamingSystem.shared.updateInterval = 0.0 + MemoryBudgetManager.shared.clear() + MemoryBudgetManager.shared.enabled = true + MemoryBudgetManager.shared.geometryBudget = 512 * 1024 * 1024 + MemoryBudgetManager.shared.textureBudget = 256 * 1024 * 1024 + } + + override func tearDown() async throws { + stopCameraPath() + GeometryStreamingSystem.shared.reset() + GeometryStreamingSystem.shared.enabled = false + MemoryBudgetManager.shared.clear() + destroyAllEntities() + try await super.tearDown() + } + + /// Skip the default stadium/player scene — this test streams its own scene. + override func initializeAssets() { + let camera = findGameCamera() + CameraSystem.shared.activeCamera = camera + + cameraLookAt( + entityId: camera, + eye: waypoints[0].position, + target: simd_float3(0.0, 1.0, 0.0), + up: simd_float3(0.0, 1.0, 0.0) + ) + + let sun = createEntity() + createDirLight(entityId: sun) + ambientIntensity = 0.4 + renderEnvironment = true + SSAOParams.shared.enabled = false + } + + // ------------------------------------------------------------------------- + // MARK: - Reference image generator (uncomment for first run only) + + // ------------------------------------------------------------------------- + + /* + func testGenerateFlythroughReferenceImages() async throws { + let sceneRoot = try await loadRemoteScene() + + for (index, waypoint) in waypoints.enumerated() { + let name = keyframeNames[index] + snapCamera(to: waypoint) + await driveStreamingUntilReady(sceneRoot: sceneRoot) + setVisibleEntities() + for _ in 0 ..< 5 { renderer.draw(in: renderer.metalView) } + + if let tex = renderInfo.deferredRenderPassDescriptor.colorAttachments[0].texture { + testGenerateRenderTarget(targetName: name, texture: tex) + print("📸 \(name): saved to ~/Downloads/UntoldEngineRenderingTest/") + } + + if index + 1 < waypoints.count { + await animateCameraPath(from: waypoint, to: waypoints[index + 1], sceneRoot: sceneRoot) + } + } + } + */ + + // ------------------------------------------------------------------------- + // MARK: - PSNR regression test + + // ------------------------------------------------------------------------- + + func testRemoteStreamFlythrough_psnr() async throws { + let sceneRoot = try await loadRemoteScene() + + for (index, waypoint) in waypoints.enumerated() { + let name = keyframeNames[index] + + // Snap to waypoint, wait for nearby tiles to finish loading + snapCamera(to: waypoint) + let ready = await driveStreamingUntilReady(sceneRoot: sceneRoot) + if !ready { + print("⚠️ \(name): tiles did not fully settle within timeout; capturing partial frame.") + } + + // Refresh the visible-entity list (new tile geometry may have appeared) + setVisibleEntities() + + // Render a few frames to let the GPU pipeline warm up at this position + for _ in 0 ..< 5 { + renderer.draw(in: renderer.metalView) + } + + // Capture the deferred-lighting composite and PSNR-compare + guard let compositeTexture = renderInfo.deferredRenderPassDescriptor + .colorAttachments[0].texture + else { + XCTFail("\(name): deferredRenderPassDescriptor composite texture is nil") + continue + } + psnrTest(targetName: name, texture: compositeTexture) + + // Animate to the next waypoint (skip on last stop) + if index + 1 < waypoints.count { + await animateCameraPath(from: waypoint, to: waypoints[index + 1], sceneRoot: sceneRoot) + } + } + } + + // ------------------------------------------------------------------------- + // MARK: - Shared helpers + + // ------------------------------------------------------------------------- + + /// Resolves the manifest URL, loads the remote tiled scene, and returns the + /// root entity. Skips the test if no real URL is configured. + private func loadRemoteScene() async throws -> EntityID { + let urlString = ProcessInfo.processInfo.environment["UNTOLD_STREAM_MANIFEST_URL"] + ?? manifestURLString + + guard urlString != "https://cdn.example.com/dungeon/dungeon.json", + let manifestURL = URL(string: urlString) + else { + throw XCTSkip( + "No manifest URL configured. " + + "Set UNTOLD_STREAM_MANIFEST_URL or edit manifestURLString in RemoteStreamFlyThroughTests.swift." + ) + } + + let sceneRoot = createEntity() + let loaded = await withCheckedContinuation { (continuation: CheckedContinuation) in + setEntityStreamScene(entityId: sceneRoot, url: manifestURL) { @Sendable success in + continuation.resume(returning: success) + } + } + XCTAssertTrue(loaded, "Remote manifest \(urlString) should load successfully") + return sceneRoot + } + + /// Snaps the camera to `waypoint` using a single-waypoint path (instant). + private func snapCamera(to waypoint: CameraWaypoint) { + startCameraPath( + waypoints: [waypoint], + mode: .once, + settings: CameraPathSettings(startImmediately: true) + ) + } + + /// Calls GeometryStreamingSystem.update() in a 25 ms polling loop until all + /// tiles that started loading have finished parsing, then returns. + /// Returns true if the condition was met within the timeout, false otherwise. + @discardableResult + private func driveStreamingUntilReady(sceneRoot: EntityID, timeout: TimeInterval = 30.0) async -> Bool { + let camera = findGameCamera() + return await waitUntil(timeout: timeout) { + let camPos = getCameraPosition(entityId: camera) + GeometryStreamingSystem.shared.update(cameraPosition: camPos, deltaTime: 0.016) + return self.tilesAreReady(sceneRoot: sceneRoot) + } + } + + /// True when at least one tile has parsed and no tile is still mid-parse. + private func tilesAreReady(sceneRoot: EntityID) -> Bool { + let tileIds = getEntityChildren(parentId: sceneRoot) + guard !tileIds.isEmpty else { return false } + let hasParsed = tileIds.contains { + scene.get(component: TileComponent.self, for: $0)?.state == .parsed + } + let stillParsing = tileIds.contains { + scene.get(component: TileComponent.self, for: $0)?.state == .parsing + } + return hasParsed && !stillParsing + } + + /// Simulates the camera flying from `origin` to `destination` at 60 fps + /// while streaming tiles along the path, then waits for tiles to settle. + private func animateCameraPath( + from origin: CameraWaypoint, + to destination: CameraWaypoint, + sceneRoot: EntityID + ) async { + let camera = findGameCamera() + final class Flag: @unchecked Sendable { var value = false } + let pathFinished = Flag() + + startCameraPath( + waypoints: [origin, destination], + mode: .once, + settings: CameraPathSettings(startImmediately: true) { pathFinished.value = true } + ) + + let dt: Float = 0.016 + let steps = Int(ceil(origin.segmentDuration / dt)) + 5 + for _ in 0 ..< steps { + updateCameraPath(deltaTime: dt) + let pos = getCameraPosition(entityId: camera) + GeometryStreamingSystem.shared.update(cameraPosition: pos, deltaTime: dt) + } + + XCTAssertTrue(pathFinished.value, "Camera path to destination should complete within simulated time") + + // Give any newly in-range tiles a chance to parse before the next capture + await driveStreamingUntilReady(sceneRoot: sceneRoot, timeout: 15.0) + } + + private func waitUntil( + timeout: TimeInterval, + pollIntervalNanoseconds: UInt64 = 25_000_000, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: pollIntervalNanoseconds) + } + return condition() + } +} diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png new file mode 100644 index 00000000..fee99866 Binary files /dev/null and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint1Reference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png new file mode 100644 index 00000000..b73c0ecc Binary files /dev/null and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint2Reference.png differ diff --git a/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png new file mode 100644 index 00000000..99100737 Binary files /dev/null and b/Tests/UntoldEngineRenderTests/Resources/ReferenceImages/FlythroughWaypoint3Reference.png differ diff --git a/Tests/UntoldEngineTests/BuildSystemTests.swift b/Tests/UntoldEngineTests/BuildSystemTests.swift index c3b9af28..daf3184c 100644 --- a/Tests/UntoldEngineTests/BuildSystemTests.swift +++ b/Tests/UntoldEngineTests/BuildSystemTests.swift @@ -229,12 +229,8 @@ final class BuildSystemTests: XCTestCase { "GameScene.swift should contain update method") XCTAssertTrue(gameSceneContent.contains("func handleInput()"), "GameScene.swift should contain handleInput method") - XCTAssertTrue(gameSceneContent.contains("func loadUntoldScene("), - "GameScene.swift should include the loadUntoldScene helper") - XCTAssertTrue(gameSceneContent.contains(".appendingPathExtension(\"untoldscene\")"), - "GameScene.swift should resolve .untoldscene files from GameData/Scenes") - XCTAssertTrue(gameSceneContent.contains("deserializeScene(sceneData: sceneData"), - "GameScene.swift should deserialize SceneData through the engine helper") + XCTAssertFalse(gameSceneContent.contains("func loadUntoldScene("), + "loadUntoldScene is a public engine API — it must not be redefined in GameScene.swift") // Verify GameSceneUtils content for extracted onboarding helpers let gameSceneUtilsKey = "Sources/{{PROJECT_NAME}}/GameSceneUtils.swift" @@ -934,12 +930,8 @@ final class BuildSystemTests: XCTestCase { "GameScene should have update method") XCTAssertTrue(gameSceneContent.contains("func handleInput()"), "GameScene should have handleInput method") - XCTAssertTrue(gameSceneContent.contains("func loadUntoldScene("), - "visionOS GameScene should include the loadUntoldScene helper") - XCTAssertTrue(gameSceneContent.contains(".appendingPathExtension(\"untoldscene\")"), - "visionOS GameScene should resolve .untoldscene files from GameData/Scenes") - XCTAssertTrue(gameSceneContent.contains("deserializeScene(sceneData: sceneData"), - "visionOS GameScene should deserialize SceneData through the engine helper") + XCTAssertFalse(gameSceneContent.contains("func loadUntoldScene("), + "loadUntoldScene is a public engine API — it must not be redefined in visionOS GameScene.swift") XCTAssertFalse(gameSceneContent.contains("import SwiftUI"), "GameScene should NOT import SwiftUI") XCTAssertFalse(gameSceneContent.contains("import UntoldEngineXR"), diff --git a/Tests/UntoldEngineTests/LoadUntoldSceneContractTests.swift b/Tests/UntoldEngineTests/LoadUntoldSceneContractTests.swift new file mode 100644 index 00000000..20c989f4 --- /dev/null +++ b/Tests/UntoldEngineTests/LoadUntoldSceneContractTests.swift @@ -0,0 +1,81 @@ +// +// LoadUntoldSceneContractTests.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/. +// + +@testable import UntoldEngine +import XCTest + +final class LoadUntoldSceneContractTests: XCTestCase { + private var previousAssetBasePath: URL? + private var tempRoot: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + previousAssetBasePath = assetBasePath + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("LoadUntoldSceneContractTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + assetBasePath = tempRoot + } + + override func tearDownWithError() throws { + assetBasePath = previousAssetBasePath + if let tempRoot { + try? FileManager.default.removeItem(at: tempRoot) + } + try super.tearDownWithError() + } + + func testLoadUntoldSceneAcceptsNameWithoutExtension() throws { + try writeScene(named: "LevelOne") + + let expectation = expectation(description: "scene load completes") + loadUntoldScene(named: "LevelOne", meshLoadingMode: .sync) { success in + XCTAssertTrue(success) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testLoadUntoldSceneAcceptsUntoldSceneExtension() throws { + try writeScene(named: "LevelTwo") + + let expectation = expectation(description: "scene load completes") + loadUntoldScene(named: "LevelTwo.untoldscene", meshLoadingMode: .sync) { success in + XCTAssertTrue(success) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testLoadUntoldSceneRejectsOtherExtensions() { + let expectation = expectation(description: "scene load rejects invalid extension") + loadUntoldScene(named: "LevelThree.json", meshLoadingMode: .sync) { success in + XCTAssertFalse(success) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + private func writeScene(named name: String) throws { + let scenesDirectory = tempRoot.appendingPathComponent("Scenes", isDirectory: true) + try FileManager.default.createDirectory(at: scenesDirectory, withIntermediateDirectories: true) + + let sceneURL = scenesDirectory + .appendingPathComponent(name) + .appendingPathExtension("untoldscene") + let sceneData = SceneData() + let data = try JSONEncoder().encode(sceneData) + try data.write(to: sceneURL) + } +} diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index fbbaa681..28cb5be2 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -18,6 +18,10 @@ runtime. ## Clone the Untold Engine +> **Recommendation:** Use the latest stable release instead of the `develop` +> branch. The `develop` branch is the bleeding-edge version of Untold Engine and +> is updated frequently, so it may contain unstable changes or regressions. + Clone the repository and launch the demo: ```bash