diff --git a/Package.swift b/Package.swift index fb8525ef..3fc1cedd 100644 --- a/Package.swift +++ b/Package.swift @@ -55,10 +55,41 @@ let package = Package( .library(name: "UntoldEngineAR", targets: ["UntoldEngineAR"]), - // Executable for the demo app (primary name) + // Executable for the showcase demo app (primary name) + .executable( + name: "showcasedemo", + targets: ["ShowcaseDemo"] + ), + + // Backward-compatible executable alias .executable( name: "untolddemo", - targets: ["DemoGame"] + targets: ["ShowcaseDemo"] + ), + + .executable( + name: "starterdemo", + targets: ["StarterDemo"] + ), + + .executable( + name: "largescenestreamingdemo", + targets: ["LargeSceneStreamingDemo"] + ), + + .executable( + name: "interactiongameplaydemo", + targets: ["InteractionGameplayDemo"] + ), + + .executable( + name: "renderingqualitydemo", + targets: ["RenderingQualityDemo"] + ), + + .executable( + name: "exporterpipelinedemo", + targets: ["ExporterPipelineDemo"] ), .executable( @@ -69,7 +100,7 @@ let package = Package( // Backward-compatible executable alias .executable( name: "DemoGame", - targets: ["DemoGame"] + targets: ["ShowcaseDemo"] ), ], dependencies: [], @@ -148,9 +179,69 @@ let package = Package( ), // These executables are macOS-only .executableTarget( - name: "DemoGame", + name: "ShowcaseDemo", + dependencies: ["UntoldEngine"], + path: "Sources/Demos/ShowcaseDemo", + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("QuartzCore", .when(platforms: [.macOS, .iOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ] + ), + .executableTarget( + name: "StarterDemo", + dependencies: ["UntoldEngine"], + path: "Sources/Demos/StarterDemo", + exclude: ["README.md"], + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("QuartzCore", .when(platforms: [.macOS, .iOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ] + ), + .executableTarget( + name: "LargeSceneStreamingDemo", + dependencies: ["UntoldEngine"], + path: "Sources/Demos/LargeSceneStreamingDemo", + exclude: ["README.md"], + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("QuartzCore", .when(platforms: [.macOS, .iOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ] + ), + .executableTarget( + name: "InteractionGameplayDemo", + dependencies: ["UntoldEngine"], + path: "Sources/Demos/InteractionGameplayDemo", + exclude: ["README.md"], + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("QuartzCore", .when(platforms: [.macOS, .iOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ] + ), + .executableTarget( + name: "RenderingQualityDemo", + dependencies: ["UntoldEngine"], + path: "Sources/Demos/RenderingQualityDemo", + exclude: ["README.md"], + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("QuartzCore", .when(platforms: [.macOS, .iOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ] + ), + .executableTarget( + name: "ExporterPipelineDemo", dependencies: ["UntoldEngine"], - path: "Sources/DemoGame", + path: "Sources/Demos/ExporterPipelineDemo", + exclude: ["README.md"], swiftSettings: [.swiftLanguageMode(.v6)], linkerSettings: [ .linkedFramework("Metal"), diff --git a/README.md b/README.md index 0b6b6f76..9693c094 100644 --- a/README.md +++ b/README.md @@ -65,22 +65,36 @@ http://www.haroldserrano.com ## ๐Ÿš€ Try the Engine Right Now -The fastest way to experience Untold Engine is to run the demo project. +The best first step is to run the Starter Demo. It is intentionally small and +shows the basic shape of an Untold Engine app without the extra systems used by +the larger showcase. > **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: +Clone the repository and launch the Starter Demo: ```bash git clone https://github.com/untoldengine/UntoldEngine.git cd UntoldEngine git checkout v0.13.3 -swift run untolddemo +swift run starterdemo ``` -The demo UI lets you see the engine in action right away. Using the `Remote Scene` drop-down menu, you can choose a scene to stream directly into the demo through the engine's **Asset Remote Streaming** support. +After that, run the focused demos based on what you want to learn: + +| Demo | Command | Start here when you want to learn | +| --- | --- | --- | +| Starter Demo | `swift run starterdemo` | The minimal app structure: renderer setup, camera, light, input, and a simple scene. | +| Interaction / Gameplay Demo | `swift run interactiongameplaydemo` | Gameplay-style movement, input handling, animation switching, physics pause/resume, and parented entities. | +| Rendering Quality Demo | `swift run renderingqualitydemo` | Post-processing controls such as color grading, SSAO, bloom, vignette, depth of field, anti-aliasing, and debug views. | +| Large Scene Streaming Demo | `swift run largescenestreamingdemo` | Manifest-driven tiled scene streaming, LOD, batching, streaming stats, and large-world traversal. | +| Exporter Pipeline Demo | `swift run exporterpipelinedemo` | Loading exported `.untold` assets, applying exported animation clips, and checking validation metadata. | +| Showcase Demo | `swift run showcasedemo` | A broader engine showcase that combines many systems in one app. Use this after the focused demos. | + +The demos live under `Sources/Demos`. The older `swift run untolddemo` command +still works as a compatibility alias for the Showcase Demo. ![untoldengine-image-2](/docs/images/engine-highlight-2.png) diff --git a/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift b/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift new file mode 100644 index 00000000..226b0b5c --- /dev/null +++ b/Sources/Demos/ExporterPipelineDemo/AppDelegate.swift @@ -0,0 +1,200 @@ +// +// AppDelegate.swift +// ExporterPipelineDemo +// + +#if os(macOS) + import AppKit + import Observation + import SwiftUI + import UntoldEngine + + @MainActor + @Observable + final class ExporterPipelineState { + var selectedAsset: ExportedAssetOption = .redplayer + var selectedAnimation: ExportedAnimationOption = .idle + var status = PipelineStatus() + } + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1280, height: 780) + static let minimumWindowSize = NSSize(width: 920, height: 620) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var gameScene: GameScene! + private let state = ExporterPipelineState() + + func applicationDidFinishLaunching(_: Notification) { + setupWindow() + setupRendererAndScene() + presentSceneView() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + private func setupWindow() { + window = NSWindow( + contentRect: NSRect(origin: .zero, size: Constants.windowSize), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Untold Engine Exporter Pipeline Demo" + window.minSize = Constants.minimumWindowSize + window.center() + } + + private func setupRendererAndScene() { + guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return + } + + self.renderer = renderer + gameScene = GameScene() + gameScene.onStatusChanged = { [weak state] status in + Task { @MainActor in + state?.status = status + } + } + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.gameScene.update(deltaTime: deltaTime) + }, + handleInput: { [weak self] in + self?.gameScene.handleInput() + } + ) + } + + private func presentSceneView() { + guard let renderer else { return } + + let view = ExporterPipelineDemoView( + renderer: renderer, + state: state, + actions: .init( + loadAsset: { [weak self] asset in self?.gameScene.loadAsset(asset) }, + loadAnimation: { [weak self] animation in self?.gameScene.loadAnimation(animation) }, + reset: { [weak self] in self?.gameScene.resetScene() } + ) + ) + + window.contentView = NSHostingView(rootView: view) + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + } + + private struct ExporterPipelineActions { + let loadAsset: (ExportedAssetOption) -> Void + let loadAnimation: (ExportedAnimationOption) -> Void + let reset: () -> Void + } + + private struct ExporterPipelineDemoView: View { + let renderer: UntoldRenderer + @Bindable var state: ExporterPipelineState + let actions: ExporterPipelineActions + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer) + panel + .padding(16) + } + } + + private var panel: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Exporter Pipeline") + .font(.headline) + + Picker("Asset", selection: $state.selectedAsset) { + ForEach(ExportedAssetOption.allCases) { option in + Text(option.title).tag(option) + } + } + + HStack { + Button("Load Model") { + actions.loadAsset(state.selectedAsset) + } + Button("Reset") { + actions.reset() + } + } + + Divider() + + Picker("Animation", selection: $state.selectedAnimation) { + ForEach(ExportedAnimationOption.allCases) { option in + Text(option.title).tag(option) + } + } + .disabled(!state.selectedAsset.supportsAnimation) + + Button("Load Animation") { + actions.loadAnimation(state.selectedAnimation) + } + .disabled(!state.selectedAsset.supportsAnimation) + + Divider() + + statusRows + + Text("Right-drag to orbit") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .frame(width: 390, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + + private var statusRows: some View { + VStack(alignment: .leading, spacing: 6) { + Text(state.status.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + row("Entity", state.status.loadedEntity) + row("Asset exists", state.status.assetExists ? "Yes" : "No") + row("Validation", state.status.validation.found ? "Found" : "Missing") + row("Asset name", state.status.validation.assetName) + row("Meshes", "\(state.status.validation.meshCount)") + row("Vertices", "\(state.status.validation.totalVertices)") + row("Indices", "\(state.status.validation.totalIndices)") + row("Clips", state.status.animationClips) + + Text(state.status.assetPath) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + + private func row(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .monospacedDigit() + } + .font(.caption) + } + } +#endif diff --git a/Sources/Demos/ExporterPipelineDemo/GameScene.swift b/Sources/Demos/ExporterPipelineDemo/GameScene.swift new file mode 100644 index 00000000..5bdcafa1 --- /dev/null +++ b/Sources/Demos/ExporterPipelineDemo/GameScene.swift @@ -0,0 +1,303 @@ +// +// GameScene.swift +// ExporterPipelineDemo +// + +#if os(macOS) + import Foundation + import simd + import UntoldEngine + + enum ExportedAssetOption: String, CaseIterable, Identifiable { + case stadium + case redplayer + case ball + + var id: String { + rawValue + } + + var title: String { + switch self { + case .stadium: "Stadium" + case .redplayer: "Red Player" + case .ball: "Ball" + } + } + + var supportsAnimation: Bool { + self == .redplayer + } + + var defaultScale: simd_float3 { + switch self { + case .ball: simd_float3(repeating: 0.8) + default: simd_float3(repeating: 1.0) + } + } + } + + enum ExportedAnimationOption: String, CaseIterable, Identifiable { + case idle + case running + + var id: String { + rawValue + } + + var title: String { + rawValue.capitalized + } + } + + struct ValidationSummary { + var assetName: String = "-" + var meshCount: Int = 0 + var totalVertices: Int = 0 + var totalIndices: Int = 0 + var found = false + } + + struct PipelineStatus { + var loadedEntity = "None" + var assetPath = "-" + var assetExists = false + var validation = ValidationSummary() + var animationClips = "-" + var message = "Select an exported asset." + } + + final class GameScene: @unchecked Sendable { + private enum Constants { + static let cameraEye = simd_float3(0.0, 3.5, 8.0) + static let cameraTarget = simd_float3(0.0, 0.8, 0.0) + static let orbitOffset: Float = 8.0 + } + + var onStatusChanged: (@Sendable (PipelineStatus) -> Void)? + + private var loadedEntity: EntityID? + private var loadedAsset: ExportedAssetOption? + private var status = PipelineStatus() + private var wasRightMousePressed = false + + init() { + configureEngine() + createCamera() + createLight() + loadAsset(.redplayer) + } + + func update(deltaTime _: Float) { + if gameMode == false { return } + } + + func handleInput() { + if gameMode == false { return } + if isSceneReady() == false { return } + + guard let camera = CameraSystem.shared.activeCamera else { return } + let input = InputSystem.shared + + if input.keyState.rightMousePressed { + if !wasRightMousePressed { + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitOffset) + } + orbitCameraAround(entityId: camera, uDelta: simd_float2(input.mouseDeltaX, input.mouseDeltaY)) + } + + wasRightMousePressed = input.keyState.rightMousePressed + } + + func loadAsset(_ option: ExportedAssetOption) { + setSceneReady(false) + + if let loadedEntity { + destroyEntity(entityId: loadedEntity) + self.loadedEntity = nil + } + + loadedAsset = option + status = makeStatus(for: option, message: "Loading \(option.title)...") + publishStatus() + + let entity = createEntity() + setEntityName(entityId: entity, name: option.title) + setEntityMeshAsync(entityId: entity, filename: option.rawValue, withExtension: "untold") { [weak self] success in + guard let self else { return } + + if success { + loadedEntity = entity + translateTo(entityId: entity, position: .zero) + scaleTo(entityId: entity, scale: option.defaultScale) + if option == .stadium { + rotateTo(entityId: entity, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) + } + status = makeStatus(for: option, message: "\(option.title) loaded.") + refreshAnimationClips() + } else { + status = makeStatus(for: option, message: "Failed to load \(option.title).") + } + + setSceneReady(success) + publishStatus() + } + } + + func loadAnimation(_ option: ExportedAnimationOption) { + guard let loadedEntity, loadedAsset?.supportsAnimation == true else { + status.message = "Selected asset does not support the demo animations." + publishStatus() + return + } + + setEntityAnimations( + entityId: loadedEntity, + filename: option.rawValue, + withExtension: "untold", + name: option.rawValue + ) + changeAnimation(entityId: loadedEntity, name: option.rawValue) + status.message = "Animation \(option.title) applied." + refreshAnimationClips() + publishStatus() + } + + func resetScene() { + if let loadedAsset { + loadAsset(loadedAsset) + } else { + loadAsset(.redplayer) + } + } + + private func configureEngine() { + gameMode = true + setSceneReady(false) + setEngine(.assetBasePath(Self.resourcesURL())) + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.fxaa)) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + InputSystem.shared.registerMouseEvents() + } + + static func resourcesURL() -> URL { + let sourceURL = URL(fileURLWithPath: #filePath) + let repoRoot = sourceURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return repoRoot + .appendingPathComponent("Tests") + .appendingPathComponent("UntoldEngineRenderTests") + .appendingPathComponent("Resources") + } + + private func createCamera() { + let camera = createEntity() + setEntityName(entityId: camera, name: "Pipeline Camera") + createGameCamera(entityId: camera) + cameraLookAt( + entityId: camera, + eye: Constants.cameraEye, + target: Constants.cameraTarget, + up: simd_float3(0.0, 1.0, 0.0) + ) + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitOffset) + setCamera(.active(camera)) + } + + private func createLight() { + let sun = createEntity() + setEntityName(entityId: sun, name: "Pipeline Key Light") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: -50.0, axis: simd_float3(1.0, 0.0, 0.0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.94, 0.86))) + setLight(entityId: sun, .intensity(1.5)) + setLight(entityId: sun, .directional(.active)) + } + + private func makeStatus(for option: ExportedAssetOption, message: String) -> PipelineStatus { + let assetURL = assetURL(for: option) + return PipelineStatus( + loadedEntity: loadedEntity.map { "\($0)" } ?? "None", + assetPath: assetURL.path, + assetExists: FileManager.default.fileExists(atPath: assetURL.path), + validation: validationSummary(for: option), + animationClips: status.animationClips, + message: message + ) + } + + private func assetURL(for option: ExportedAssetOption) -> URL { + Self.resourcesURL() + .appendingPathComponent("Models") + .appendingPathComponent(option.rawValue) + .appendingPathComponent("\(option.rawValue).untold") + } + + private func validationURL(for option: ExportedAssetOption) -> URL { + Self.resourcesURL() + .appendingPathComponent("Models") + .appendingPathComponent(option.rawValue) + .appendingPathComponent("\(option.rawValue).validation.json") + } + + private func validationSummary(for option: ExportedAssetOption) -> ValidationSummary { + let url = validationURL(for: option) + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(ValidationFile.self, from: data) + else { + return ValidationSummary(found: false) + } + + return ValidationSummary( + assetName: decoded.assetName, + meshCount: decoded.meshCount, + totalVertices: decoded.meshes.reduce(0) { $0 + $1.vertexCount }, + totalIndices: decoded.meshes.reduce(0) { $0 + $1.indexCount }, + found: true + ) + } + + private func refreshAnimationClips() { + guard let loadedEntity else { + status.animationClips = "-" + return + } + + let clips = getAllAnimationClips(entityId: loadedEntity).sorted() + status.loadedEntity = "\(loadedEntity)" + status.animationClips = clips.isEmpty ? "None" : clips.joined(separator: ", ") + } + + private func publishStatus() { + onStatusChanged?(status) + } + } + + private struct ValidationFile: Decodable { + let assetName: String + let meshCount: Int + let meshes: [ValidationMesh] + + enum CodingKeys: String, CodingKey { + case assetName = "asset_name" + case meshCount = "mesh_count" + case meshes + } + } + + private struct ValidationMesh: Decodable { + let vertexCount: Int + let indexCount: Int + + enum CodingKeys: String, CodingKey { + case vertexCount = "vertex_count" + case indexCount = "index_count" + } + } +#endif diff --git a/Sources/Demos/ExporterPipelineDemo/README.md b/Sources/Demos/ExporterPipelineDemo/README.md new file mode 100644 index 00000000..3c47daa1 --- /dev/null +++ b/Sources/Demos/ExporterPipelineDemo/README.md @@ -0,0 +1,21 @@ +# Exporter Pipeline Demo + +Focused demo for the exported-asset runtime path. + +Run it from the repository root: + +```bash +swift run exporterpipelinedemo +``` + +What it demonstrates: + +- setting the asset base path with `setEngine(.assetBasePath(...))` +- loading exported `.untold` models with `setEntityMeshAsync` +- loading exported `.untold` animation clips with `setEntityAnimations` +- switching animation clips with `changeAnimation` +- checking whether sibling `*.validation.json` files exist +- showing basic validation metadata: asset name, mesh count, vertex totals, and index totals + +This demo does not invoke Blender or export files. It shows the runtime side of +the pipeline after assets have already been exported. diff --git a/Sources/Demos/ExporterPipelineDemo/main.swift b/Sources/Demos/ExporterPipelineDemo/main.swift new file mode 100644 index 00000000..e9ef7487 --- /dev/null +++ b/Sources/Demos/ExporterPipelineDemo/main.swift @@ -0,0 +1,13 @@ +// +// main.swift +// ExporterPipelineDemo +// + +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif diff --git a/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift b/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift new file mode 100644 index 00000000..9aada6bf --- /dev/null +++ b/Sources/Demos/InteractionGameplayDemo/AppDelegate.swift @@ -0,0 +1,96 @@ +// +// AppDelegate.swift +// InteractionGameplayDemo +// + +#if os(macOS) + import AppKit + import SwiftUI + import UntoldEngine + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1280, height: 760) + static let minimumWindowSize = NSSize(width: 900, height: 620) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var gameScene: GameScene! + + func applicationDidFinishLaunching(_: Notification) { + setupWindow() + setupRendererAndScene() + presentSceneView() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + private func setupWindow() { + window = NSWindow( + contentRect: NSRect(origin: .zero, size: Constants.windowSize), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Untold Engine Interaction / Gameplay Demo" + window.minSize = Constants.minimumWindowSize + window.center() + } + + private func setupRendererAndScene() { + guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return + } + + self.renderer = renderer + gameScene = GameScene() + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.gameScene.update(deltaTime: deltaTime) + }, + handleInput: { [weak self] in + self?.gameScene.handleInput() + } + ) + } + + private func presentSceneView() { + guard let renderer else { return } + + window.contentView = NSHostingView( + rootView: InteractionGameplayDemoView(renderer: renderer) + ) + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + } + + private struct InteractionGameplayDemoView: View { + let renderer: UntoldRenderer + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer) + + VStack(alignment: .leading, spacing: 6) { + Text("Interaction / Gameplay") + .font(.headline) + Text("WASD moves the player") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(16) + } + } + } +#endif diff --git a/Sources/Demos/InteractionGameplayDemo/GameScene.swift b/Sources/Demos/InteractionGameplayDemo/GameScene.swift new file mode 100644 index 00000000..acb59970 --- /dev/null +++ b/Sources/Demos/InteractionGameplayDemo/GameScene.swift @@ -0,0 +1,219 @@ +// +// GameScene.swift +// InteractionGameplayDemo +// + +#if os(macOS) + import Foundation + import simd + import UntoldEngine + + final class GameScene: @unchecked Sendable { + private enum Constants { + static let cameraEye = simd_float3(0.0, 7.0, 15.0) + static let cameraTarget = simd_float3(0.0, 0.0, 0.0) + static let playerStart = simd_float3(0.0, 0.0, 0.0) + static let ballLocalOffset = simd_float3(0.0, 0.6, 1.0) + static let maxPlayerSpeed: Float = 5.0 + static let turnSpeed: Float = 5.0 + static let ballRollDegreesPerSecond: Float = 240.0 + } + + private var stadium: EntityID? + private var redPlayer: EntityID? + private var ball: EntityID? + private var startMoving = false + private var currentAnimation = "idle" + private var ballAttached = false + + init() { + configureEngine() + createCamera() + createLight() + loadScene() + } + + func update(deltaTime: Float) { + if gameMode == false { return } + if isSceneReady() == false { return } + guard let redPlayer else { return } + + if startMoving { + playAnimationIfNeeded("running") + pausePhysicsComponent(entityId: redPlayer, isPaused: false) + } else { + playAnimationIfNeeded("idle") + pausePhysicsComponent(entityId: redPlayer, isPaused: true) + return + } + + let targetPosition = movementTarget(from: getPosition(entityId: redPlayer)) + steerSeek( + entityId: redPlayer, + targetPosition: targetPosition, + maxSpeed: Constants.maxPlayerSpeed, + deltaTime: deltaTime, + turnSpeed: Constants.turnSpeed + ) + + if let ball { + rotateBy( + entityId: ball, + angle: Constants.ballRollDegreesPerSecond * deltaTime, + axis: getRightAxisVector(entityId: ball) + ) + } + } + + func handleInput() { + if gameMode == false { return } + if isSceneReady() == false { return } + + let input = InputSystem.shared.keyState + startMoving = input.wPressed || input.aPressed || input.sPressed || input.dPressed + } + + private func configureEngine() { + gameMode = true + setSceneReady(false) + setEngine(.assetBasePath(Self.resourcesURL())) + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.fxaa)) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + InputSystem.shared.registerKeyboardEvents() + } + + private static func resourcesURL() -> URL { + let sourceURL = URL(fileURLWithPath: #filePath) + let repoRoot = sourceURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return repoRoot + .appendingPathComponent("Tests") + .appendingPathComponent("UntoldEngineRenderTests") + .appendingPathComponent("Resources") + } + + private func createCamera() { + let camera = createEntity() + setEntityName(entityId: camera, name: "Gameplay Camera") + createGameCamera(entityId: camera) + cameraLookAt( + entityId: camera, + eye: Constants.cameraEye, + target: Constants.cameraTarget, + up: simd_float3(0.0, 1.0, 0.0) + ) + setCamera(.active(camera)) + } + + private func createLight() { + let sun = createEntity() + setEntityName(entityId: sun, name: "Sun") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: -55.0, axis: simd_float3(1.0, 0.0, 0.0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.94, 0.86))) + setLight(entityId: sun, .intensity(1.6)) + setLight(entityId: sun, .directional(.active)) + } + + private func loadScene() { + loadStadium { [weak self] entity, success in + self?.stadium = entity + if success == false { + Logger.log(message: "Failed to load stadium") + } + } + + loadPlayer { [weak self] entity, success in + self?.redPlayer = entity + self?.attachBallToPlayerIfReady() + setSceneReady(success) + } + + loadBall { [weak self] entity, success in + self?.ball = entity + self?.attachBallToPlayerIfReady() + if success == false { + Logger.log(message: "Failed to load ball") + } + } + } + + private func loadStadium(completion: @escaping @Sendable (EntityID?, Bool) -> Void) { + let entity = createEntity() + setEntityName(entityId: entity, name: "Stadium") + setEntityMeshAsync(entityId: entity, filename: "stadium", withExtension: "untold") { success in + guard success else { + completion(nil, false) + return + } + + rotateTo(entityId: entity, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) + completion(entity, true) + } + } + + private func loadPlayer(completion: @escaping @Sendable (EntityID?, Bool) -> Void) { + let entity = createEntity() + setEntityName(entityId: entity, name: "Red Player") + setEntityMeshAsync(entityId: entity, filename: "redplayer", withExtension: "untold") { success in + guard success else { + completion(nil, false) + return + } + + translateTo(entityId: entity, position: Constants.playerStart) + setEntityAnimations(entityId: entity, filename: "running", withExtension: "untold", name: "running") + setEntityAnimations(entityId: entity, filename: "idle", withExtension: "untold", name: "idle") + changeAnimation(entityId: entity, name: "idle") + setEntityKinetics(entityId: entity) + setGravityScale(entityId: entity, gravityScale: 0.0) + setLinearDragCoefficient(entityId: entity, coefficients: simd_float2(1.5, 0.2)) + pausePhysicsComponent(entityId: entity, isPaused: true) + completion(entity, true) + } + } + + private func loadBall(completion: @escaping @Sendable (EntityID?, Bool) -> Void) { + let entity = createEntity() + setEntityName(entityId: entity, name: "Ball") + setEntityMeshAsync(entityId: entity, filename: "ball", withExtension: "untold") { success in + guard success else { + completion(nil, false) + return + } + + translateTo(entityId: entity, position: Constants.ballLocalOffset) + completion(entity, true) + } + } + + private func attachBallToPlayerIfReady() { + guard ballAttached == false, let ball, let redPlayer else { return } + setParent(childId: ball, parentId: redPlayer) + ballAttached = true + } + + private func movementTarget(from currentPosition: simd_float3) -> simd_float3 { + let input = InputSystem.shared.keyState + var targetPosition = currentPosition + + if input.wPressed { targetPosition.z += 1.0 } + if input.sPressed { targetPosition.z -= 1.0 } + if input.aPressed { targetPosition.x -= 1.0 } + if input.dPressed { targetPosition.x += 1.0 } + + return targetPosition + } + + private func playAnimationIfNeeded(_ name: String) { + guard let redPlayer, currentAnimation != name else { return } + currentAnimation = name + changeAnimation(entityId: redPlayer, name: name) + } + } +#endif diff --git a/Sources/Demos/InteractionGameplayDemo/README.md b/Sources/Demos/InteractionGameplayDemo/README.md new file mode 100644 index 00000000..b03f9e2b --- /dev/null +++ b/Sources/Demos/InteractionGameplayDemo/README.md @@ -0,0 +1,22 @@ +# Interaction / Gameplay Demo + +Focused demo for a small gameplay loop using Untold Engine APIs. + +Run it from the repository root: + +```bash +swift run interactiongameplaydemo +``` + +What it demonstrates: + +- loading `.untold` gameplay assets from `Tests/UntoldEngineRenderTests/Resources` +- player input with `InputSystem` +- idle/running animation switching with `setEntityAnimations` and `changeAnimation` +- simple player movement through `setEntityKinetics` and `steerSeek` +- parenting the ball to the player with `setParent` +- rotating the child ball while the player moves + +Controls: + +- `WASD` moves the player diff --git a/Sources/Demos/InteractionGameplayDemo/main.swift b/Sources/Demos/InteractionGameplayDemo/main.swift new file mode 100644 index 00000000..542ce925 --- /dev/null +++ b/Sources/Demos/InteractionGameplayDemo/main.swift @@ -0,0 +1,13 @@ +// +// main.swift +// InteractionGameplayDemo +// + +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif diff --git a/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift b/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift new file mode 100644 index 00000000..c55ce9ed --- /dev/null +++ b/Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift @@ -0,0 +1,217 @@ +// +// AppDelegate.swift +// LargeSceneStreamingDemo +// + +#if os(macOS) + import AppKit + import Observation + import SwiftUI + import UntoldEngine + + @MainActor + @Observable + final class LargeSceneStreamingState { + var status = "Loading default remote scene..." + var customManifestURL = "" + var isLoading = false + var tileBoundsEnabled = true + var lodDebugEnabled = false + var textureTierDebugEnabled = false + var showStats = true + } + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1440, height: 860) + static let minimumWindowSize = NSSize(width: 1000, height: 680) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var gameScene: GameScene! + private let state = LargeSceneStreamingState() + + func applicationDidFinishLaunching(_: Notification) { + setupWindow() + setupRendererAndScene() + presentSceneView() + gameScene.loadPreset(.dungeon) + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + private func setupWindow() { + window = NSWindow( + contentRect: NSRect(origin: .zero, size: Constants.windowSize), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Untold Engine Large Scene Streaming Demo" + window.minSize = Constants.minimumWindowSize + window.center() + } + + private func setupRendererAndScene() { + guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return + } + + self.renderer = renderer + gameScene = GameScene() + gameScene.onStatusChanged = { [weak state] message, isLoading in + Task { @MainActor in + state?.status = message + state?.isLoading = isLoading + } + } + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.gameScene.update(deltaTime: deltaTime) + }, + handleInput: { [weak self] in + self?.gameScene.handleInput() + } + ) + } + + private func presentSceneView() { + guard let renderer else { return } + + let view = LargeSceneStreamingDemoView( + renderer: renderer, + state: state, + actions: .init( + loadPreset: { [weak self] preset in self?.gameScene.loadPreset(preset) }, + loadCustomURL: { [weak self] url in self?.gameScene.loadManifest(url: url, label: "Custom Manifest") }, + loadFallbackField: { [weak self] in self?.gameScene.loadFallbackField() }, + setTileBounds: { [weak self] enabled in self?.gameScene.setTileBoundsDebug(enabled) }, + setLodDebug: { [weak self] enabled in self?.gameScene.setLodDebug(enabled) }, + setTextureTierDebug: { [weak self] enabled in self?.gameScene.setTextureTierDebug(enabled) } + ) + ) + + window.contentView = NSHostingView(rootView: view) + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + } + + private struct LargeSceneStreamingActions { + let loadPreset: (GameScene.RemoteScenePreset) -> Void + let loadCustomURL: (URL) -> Void + let loadFallbackField: () -> Void + let setTileBounds: (Bool) -> Void + let setLodDebug: (Bool) -> Void + let setTextureTierDebug: (Bool) -> Void + } + + private struct LargeSceneStreamingDemoView: View { + let renderer: UntoldRenderer + @Bindable var state: LargeSceneStreamingState + let actions: LargeSceneStreamingActions + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer) + + HStack(alignment: .top, spacing: 12) { + controls + if state.showStats { + statsPanel + } + } + .padding(16) + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Large Scene Streaming") + .font(.headline) + Text(state.status) + .font(.caption) + .foregroundStyle(state.isLoading ? .orange : .secondary) + .lineLimit(2) + } + + HStack { + Button("Dungeon") { actions.loadPreset(.dungeon) } + Button("City") { actions.loadPreset(.city) } + Button("Field") { actions.loadFallbackField() } + } + + HStack { + TextField("https://.../scene.json or file:///...", text: $state.customManifestURL) + .textFieldStyle(.roundedBorder) + .frame(width: 320) + Button("Load") { + guard let url = URL(string: state.customManifestURL), url.scheme != nil else { + state.status = "Enter a full manifest URL." + return + } + actions.loadCustomURL(url) + } + } + + Toggle("Tile Bounds", isOn: $state.tileBoundsEnabled) + .onChange(of: state.tileBoundsEnabled) { _, enabled in actions.setTileBounds(enabled) } + Toggle("LOD Debug", isOn: $state.lodDebugEnabled) + .onChange(of: state.lodDebugEnabled) { _, enabled in actions.setLodDebug(enabled) } + Toggle("Texture Tier Debug", isOn: $state.textureTierDebugEnabled) + .onChange(of: state.textureTierDebugEnabled) { _, enabled in actions.setTextureTierDebug(enabled) } + Toggle("Stats", isOn: $state.showStats) + + Text("WASD move | Q/E up-down | Right-drag orbit") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .frame(width: 420, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + + private var statsPanel: some View { + TimelineView(.periodic(from: .now, by: 0.5)) { _ in + let stats = getEngineStatsSnapshot() + + VStack(alignment: .leading, spacing: 6) { + Text("Streaming Stats") + .font(.headline) + stat("Active loads", stats.streaming.activeLoads) + stat("Candidates", stats.streaming.loadCandidates) + stat("Loaded mesh entities", stats.streaming.residentMeshEntities) + stat("Full tiles visible", stats.streaming.visibleFullTileRepresentations) + stat("LOD visible", stats.streaming.visibleLODRepresentations) + stat("HLOD visible", stats.streaming.visibleHLODRepresentations) + stat("Batch groups", stats.batching.batchGroupCount) + stat("Draw calls", stats.render.drawCallsTotal) + stat("Mesh memory", "\(stats.memory.meshMemoryBytes / (1024 * 1024)) MB") + } + .padding(12) + .frame(width: 230, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + } + + private func stat(_ label: String, _ value: some CustomStringConvertible) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value.description) + .monospacedDigit() + } + .font(.caption) + } + } +#endif diff --git a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift new file mode 100644 index 00000000..454a5c62 --- /dev/null +++ b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift @@ -0,0 +1,245 @@ +// +// GameScene.swift +// LargeSceneStreamingDemo +// + +#if os(macOS) + import Foundation + import simd + import SwiftUI + import UntoldEngine + + final class GameScene: @unchecked Sendable { + enum RemoteScenePreset: String, CaseIterable { + case dungeon = "Dungeon" + case city = "City" + + var manifestURL: URL { + switch self { + case .dungeon: + URL(string: "https://d8pyi1c08k1w.cloudfront.net/dungeon3/dungeon3.json")! + case .city: + URL(string: "https://d8pyi1c08k1w.cloudfront.net/city/city.json")! + } + } + + var cameraEye: simd_float3 { + switch self { + case .dungeon: simd_float3(0.0, 4.0, 18.0) + case .city: simd_float3(0.0, 18.35, 73.56) + } + } + } + + private enum Constants { + static let cameraMoveSpeed: Float = 9.0 + static let cameraInputDeltaTime: Float = 1.0 / 60.0 + static let orbitTargetOffset: Float = 25.0 + static let fallbackGridSize = 28 + static let fallbackSpacing: Float = 4.0 + static let worldOrigin = simd_float3(0.0, 0.0, 0.0) + } + + var onStatusChanged: (@Sendable (String, Bool) -> Void)? + + private var streamedSceneRoot: EntityID? + private var fallbackEntities: [EntityID] = [] + private var wasRightMousePressed = false + + init() { + configureEngine() + createCamera() + createLight() + setSceneReady(false) + } + + func loadPreset(_ preset: RemoteScenePreset) { + placeCamera(eye: preset.cameraEye) + loadManifest(url: preset.manifestURL, label: preset.rawValue) + } + + func loadManifest(url: URL, label: String) { + clearLoadedContent() + setSceneReady(false) + setGeometryStreaming(.enabled(true)) + setStatus("Loading \(label)...", isLoading: true) + + let root = createEntity() + setEntityName(entityId: root, name: "\(label) Stream Root") + streamedSceneRoot = root + + setEntityStreamScene(entityId: root, url: url) { [weak self] success in + guard let self else { return } + if success { + setSceneReady(true) + setStatus("\(label) manifest registered. Move the camera to stream tiles.", isLoading: false) + } else { + setSceneReady(true) + setStatus("Could not load \(label). Use Field for offline fallback.", isLoading: false) + if streamedSceneRoot == root { + streamedSceneRoot = nil + } + } + } + } + + func loadFallbackField() { + clearLoadedContent() + setGeometryStreaming(.enabled(false)) + setSceneReady(false) + setStatus("Building procedural reference field...", isLoading: true) + + let cubeMesh = BasicPrimitives.createCube(extent: 0.9) + let half = Constants.fallbackGridSize / 2 + + for z in -half ..< half { + for x in -half ..< half { + let entity = createEntity() + setEntityName(entityId: entity, name: "Fallback_\(x)_\(z)") + setEntityMeshDirect(entityId: entity, meshes: cubeMesh, assetName: "fallback_cube") + translateTo( + entityId: entity, + position: simd_float3( + Float(x) * Constants.fallbackSpacing, + 0.0, + Float(z) * Constants.fallbackSpacing + ) + ) + let hue = Double((x + half) % 8) / 8.0 + updateMaterialColor( + entityId: entity, + color: Color(hue: hue, saturation: 0.62, brightness: 0.86) + ) + setEntityStaticBatchComponent(entityId: entity) + fallbackEntities.append(entity) + } + } + + setBatching(.enabled(true)) + generateBatches() + placeCamera(eye: simd_float3(0.0, 24.0, 72.0)) + setSceneReady(true) + setStatus("Offline field loaded. Use remote/custom manifest for true tile streaming.", isLoading: false) + } + + func update(deltaTime _: Float) { + guard gameMode else { return } + } + + func handleInput() { + guard gameMode, isSceneReady() else { return } + guard let camera = CameraSystem.shared.activeCamera else { return } + + let input = InputSystem.shared + moveCameraWithInput( + entityId: camera, + input: ( + w: input.keyState.wPressed, + a: input.keyState.aPressed, + s: input.keyState.sPressed, + d: input.keyState.dPressed, + q: input.keyState.qPressed, + e: input.keyState.ePressed + ), + speed: Constants.cameraMoveSpeed, + deltaTime: Constants.cameraInputDeltaTime + ) + + if input.keyState.rightMousePressed { + if !wasRightMousePressed { + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) + } + orbitCameraAround( + entityId: camera, + uDelta: simd_float2(input.mouseDeltaX, input.mouseDeltaY) + ) + } + + wasRightMousePressed = input.keyState.rightMousePressed + } + + func setTileBoundsDebug(_ enabled: Bool) { + setSpatialDebug(.tileBounds(enabled: enabled)) + } + + func setLodDebug(_ enabled: Bool) { + setSpatialDebug(.lodLevels(enabled)) + } + + func setTextureTierDebug(_ enabled: Bool) { + setSpatialDebug(.textureStreamingTiers(enabled)) + } + + private func configureEngine() { + gameMode = true + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.fxaa)) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + + setGeometryStreaming(.enabled(true)) + setGeometryStreaming(.tileConcurrency(2)) + setGeometryStreaming(.meshConcurrency(3)) + setGeometryStreaming(.lodConcurrency(4)) + setGeometryStreaming(.hlodConcurrency(4)) + setGeometryStreaming(.queryRadius(650.0)) + setGeometryStreaming(.frustumGate(.enabled(meshPadding: 6.0, tilePadding: 30.0))) + setGeometryStreaming(.velocityLookAhead(time: 0.5, minSpeed: 1.5)) + setGeometryStreaming(.candidateSorting(importance: true, occlusion: true)) + + setBatching(.enabled(true)) + setSpatialDebug(.tileBounds(enabled: true)) + + InputSystem.shared.registerKeyboardEvents() + InputSystem.shared.registerMouseEvents() + } + + private func createCamera() { + let camera = createEntity() + setEntityName(entityId: camera, name: "Streaming Camera") + createGameCamera(entityId: camera) + setCamera(.active(camera)) + placeCamera(eye: RemoteScenePreset.dungeon.cameraEye) + } + + private func createLight() { + let sun = createEntity() + setEntityName(entityId: sun, name: "Key Light") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: -45.0, axis: simd_float3(1.0, 0.0, 0.0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.94, 0.86))) + setLight(entityId: sun, .intensity(1.4)) + setLight(entityId: sun, .directional(.active)) + } + + private func placeCamera(eye: simd_float3) { + guard let camera = CameraSystem.shared.activeCamera else { return } + cameraLookAt( + entityId: camera, + eye: eye, + target: Constants.worldOrigin, + up: simd_float3(0.0, 1.0, 0.0) + ) + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) + } + + private func clearLoadedContent() { + GeometryStreamingSystem.shared.forceUnloadAllParsedTiles() + + if let streamedSceneRoot { + destroyEntity(entityId: streamedSceneRoot) + self.streamedSceneRoot = nil + } + + for entity in fallbackEntities { + destroyEntity(entityId: entity) + } + fallbackEntities.removeAll() + clearSceneBatches() + } + + private func setStatus(_ message: String, isLoading: Bool) { + onStatusChanged?(message, isLoading) + } + } +#endif diff --git a/Sources/Demos/LargeSceneStreamingDemo/README.md b/Sources/Demos/LargeSceneStreamingDemo/README.md new file mode 100644 index 00000000..53c9f623 --- /dev/null +++ b/Sources/Demos/LargeSceneStreamingDemo/README.md @@ -0,0 +1,25 @@ +# Large Scene Streaming Demo + +Focused demo for Untold Engine's manifest-driven tiled-scene streaming path. + +Run it from the repository root: + +```bash +swift run largescenestreamingdemo +``` + +What it demonstrates: + +- loading a remote tiled-scene manifest with `setEntityStreamScene(entityId:url:)` +- fly-camera traversal through streamed content +- geometry streaming runtime tuning through `setGeometryStreaming(...)` +- automatic runtime batching for streamed tiles +- tile bounds, LOD debug, and texture tier debug overlays +- live engine stats for streaming, batching, draw calls, and memory + +The default remote scenes reuse the same public manifests as `ShowcaseDemo`. The +`Field` button loads a procedural offline reference field so the executable still +runs without network access, but that mode is not tile streaming. + +To test your own exported world, paste a full `https://.../scene.json` or +`file:///.../scene.json` manifest URL into the custom manifest field. diff --git a/Sources/Demos/LargeSceneStreamingDemo/main.swift b/Sources/Demos/LargeSceneStreamingDemo/main.swift new file mode 100644 index 00000000..0be3effa --- /dev/null +++ b/Sources/Demos/LargeSceneStreamingDemo/main.swift @@ -0,0 +1,13 @@ +// +// main.swift +// LargeSceneStreamingDemo +// + +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif diff --git a/Sources/Demos/RenderingQualityDemo/AppDelegate.swift b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift new file mode 100644 index 00000000..99cbd822 --- /dev/null +++ b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift @@ -0,0 +1,484 @@ +// +// AppDelegate.swift +// RenderingQualityDemo +// + +#if os(macOS) + import AppKit + import Observation + import SwiftUI + import UntoldEngine + + @MainActor + @Observable + final class RenderingQualityState { + var selectedPreset: QualityPreset = .neutral + var selectedAA: AntiAliasingOption = .fxaa + var selectedDebugView: DebugViewOption = .lit + + var colorGradingEnabled = false + var exposure = 0.0 + var brightness = 0.0 + var contrast = 1.0 + var saturation = 1.0 + var temperature = 0.0 + var tint = 0.0 + + var ssaoEnabled = false + var ssaoQuality: SSAOQualityOption = .balanced + var ssaoRadius = 0.75 + var ssaoBias = 0.02 + var ssaoIntensity = 0.65 + + var bloomEnabled = false + var bloomThreshold = 0.62 + var bloomThresholdIntensity = 0.5 + var bloomCompositeIntensity = 0.65 + + var vignetteEnabled = false + var vignetteIntensity = 0.32 + var vignetteRadius = 0.82 + var vignetteSoftness = 0.42 + + var depthOfFieldEnabled = false + var focusDistance = 11.0 + var focusRange = 3.5 + var maxBlur = 6.0 + + var chromaticAberrationEnabled = false + var chromaticAberrationIntensity = 0.012 + } + + enum QualityPreset: String, CaseIterable, Identifiable { + case neutral = "Neutral" + case cinematic = "Cinematic" + case inspection = "Inspection" + + var id: String { + rawValue + } + } + + enum AntiAliasingOption: String, CaseIterable, Identifiable { + case none = "None" + case fxaa = "FXAA" + case smaa = "SMAA" + case msaa = "MSAA" + + var id: String { + rawValue + } + + var mode: AntiAliasingMode { + switch self { + case .none: .none + case .fxaa: .fxaa + case .smaa: .smaa + case .msaa: .msaa + } + } + } + + enum SSAOQualityOption: String, CaseIterable, Identifiable { + case fast = "Fast" + case balanced = "Balanced" + case high = "High" + + var id: String { + rawValue + } + + var quality: SSAOQuality { + switch self { + case .fast: .fast + case .balanced: .balanced + case .high: .high + } + } + } + + enum DebugViewOption: String, CaseIterable, Identifiable { + case lit = "Lit" + case albedo = "Albedo" + case normal = "Normal" + case depth = "Depth" + case ssao = "SSAO" + case fxaaEdges = "FXAA Edges" + case smaaEdges = "SMAA Edges" + case smaaBlend = "SMAA Blend" + case smaaDifference = "SMAA Diff" + + var id: String { + rawValue + } + + var mode: RenderDebugViewMode { + switch self { + case .lit: .lit + case .albedo: .albedo + case .normal: .normal + case .depth: .depth + case .ssao: .ssaoBlurred + case .fxaaEdges: .fxaaEdgeDebug + case .smaaEdges: .smaaEdges + case .smaaBlend: .smaaBlend + case .smaaDifference: .smaaDifference + } + } + } + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1360, height: 820) + static let minimumWindowSize = NSSize(width: 980, height: 660) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var gameScene: GameScene! + private let state = RenderingQualityState() + + func applicationDidFinishLaunching(_: Notification) { + setupWindow() + setupRendererAndScene() + presentSceneView() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + private func setupWindow() { + window = NSWindow( + contentRect: NSRect(origin: .zero, size: Constants.windowSize), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Untold Engine Rendering Quality Demo" + window.minSize = Constants.minimumWindowSize + window.center() + } + + private func setupRendererAndScene() { + guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return + } + + self.renderer = renderer + gameScene = GameScene() + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.gameScene.update(deltaTime: deltaTime) + }, + handleInput: { [weak self] in + self?.gameScene.handleInput() + } + ) + } + + private func presentSceneView() { + guard let renderer else { return } + + window.contentView = NSHostingView( + rootView: RenderingQualityDemoView( + renderer: renderer, + state: state, + actions: makeActions() + ) + ) + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + private func makeActions() -> RenderingQualityActions { + .init( + applyPreset: { [weak self] preset in self?.applyPreset(preset) }, + setAA: { [weak self] option in self?.gameScene.setAntiAliasing(option.mode) }, + setDebugView: { [weak self] option in self?.gameScene.setDebugView(option.mode) }, + applyColorGrading: { [weak self] in self?.applyColorGrading() }, + applySSAO: { [weak self] in self?.applySSAO() }, + applyBloom: { [weak self] in self?.applyBloom() }, + applyVignette: { [weak self] in self?.applyVignette() }, + applyDepthOfField: { [weak self] in self?.applyDepthOfField() }, + applyChromaticAberration: { [weak self] in self?.applyChromaticAberration() } + ) + } + + private func applyPreset(_ preset: QualityPreset) { + state.selectedPreset = preset + state.selectedDebugView = .lit + + switch preset { + case .neutral: + state.selectedAA = .fxaa + state.colorGradingEnabled = false + state.exposure = 0.0 + state.brightness = 0.0 + state.contrast = 1.0 + state.saturation = 1.0 + state.temperature = 0.0 + state.tint = 0.0 + state.ssaoEnabled = false + state.bloomEnabled = false + state.vignetteEnabled = false + state.depthOfFieldEnabled = false + state.chromaticAberrationEnabled = false + gameScene.applyNeutralLook() + case .cinematic: + state.selectedAA = .smaa + state.colorGradingEnabled = true + state.exposure = -0.2 + state.brightness = -0.05 + state.contrast = 1.15 + state.saturation = 0.9 + state.temperature = 0.0 + state.tint = 0.0 + state.ssaoEnabled = true + state.ssaoQuality = .balanced + state.ssaoRadius = 0.5 + state.ssaoBias = 0.02 + state.ssaoIntensity = 0.5 + state.bloomEnabled = true + state.bloomThreshold = 0.62 + state.bloomThresholdIntensity = 0.45 + state.bloomCompositeIntensity = 0.55 + state.vignetteEnabled = true + state.vignetteIntensity = 0.28 + state.vignetteRadius = 0.82 + state.vignetteSoftness = 0.42 + state.depthOfFieldEnabled = false + state.chromaticAberrationEnabled = false + gameScene.applyCinematicLook() + case .inspection: + state.selectedAA = .smaa + state.colorGradingEnabled = true + state.exposure = 0.15 + state.brightness = 0.05 + state.contrast = 1.1 + state.saturation = 0.95 + state.temperature = 0.08 + state.tint = 0.0 + state.ssaoEnabled = true + state.ssaoQuality = .high + state.ssaoRadius = 0.85 + state.ssaoBias = 0.02 + state.ssaoIntensity = 0.7 + state.bloomEnabled = false + state.vignetteEnabled = false + state.depthOfFieldEnabled = false + state.chromaticAberrationEnabled = false + gameScene.applyInspectionLook() + } + } + + private func applyColorGrading() { + gameScene.setColorGrading( + enabled: state.colorGradingEnabled, + exposure: Float(state.exposure), + brightness: Float(state.brightness), + contrast: Float(state.contrast), + saturation: Float(state.saturation), + temperature: Float(state.temperature), + tint: Float(state.tint) + ) + } + + private func applySSAO() { + gameScene.setSSAO( + enabled: state.ssaoEnabled, + radius: Float(state.ssaoRadius), + bias: Float(state.ssaoBias), + intensity: Float(state.ssaoIntensity), + quality: state.ssaoQuality.quality + ) + } + + private func applyBloom() { + gameScene.setBloom( + enabled: state.bloomEnabled, + threshold: Float(state.bloomThreshold), + thresholdIntensity: Float(state.bloomThresholdIntensity), + compositeIntensity: Float(state.bloomCompositeIntensity) + ) + } + + private func applyVignette() { + gameScene.setVignette( + enabled: state.vignetteEnabled, + intensity: Float(state.vignetteIntensity), + radius: Float(state.vignetteRadius), + softness: Float(state.vignetteSoftness) + ) + } + + private func applyDepthOfField() { + gameScene.setDepthOfField( + enabled: state.depthOfFieldEnabled, + focusDistance: Float(state.focusDistance), + focusRange: Float(state.focusRange), + maxBlur: Float(state.maxBlur) + ) + } + + private func applyChromaticAberration() { + gameScene.setChromaticAberration( + enabled: state.chromaticAberrationEnabled, + intensity: Float(state.chromaticAberrationIntensity) + ) + } + } + + private struct RenderingQualityActions { + let applyPreset: (QualityPreset) -> Void + let setAA: (AntiAliasingOption) -> Void + let setDebugView: (DebugViewOption) -> Void + let applyColorGrading: () -> Void + let applySSAO: () -> Void + let applyBloom: () -> Void + let applyVignette: () -> Void + let applyDepthOfField: () -> Void + let applyChromaticAberration: () -> Void + } + + private struct RenderingQualityDemoView: View { + let renderer: UntoldRenderer + @Bindable var state: RenderingQualityState + let actions: RenderingQualityActions + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer) + + ScrollView { + controls + } + .frame(width: 390, height: 720) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(16) + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Rendering Quality") + .font(.headline) + + Picker("Preset", selection: $state.selectedPreset) { + ForEach(QualityPreset.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.segmented) + .onChange(of: state.selectedPreset) { _, value in actions.applyPreset(value) } + + Picker("AA", selection: $state.selectedAA) { + ForEach(AntiAliasingOption.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.segmented) + .onChange(of: state.selectedAA) { _, value in actions.setAA(value) } + + Picker("Debug View", selection: $state.selectedDebugView) { + ForEach(DebugViewOption.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .onChange(of: state.selectedDebugView) { _, value in actions.setDebugView(value) } + + Divider() + section("Color") + Toggle("Color Grading", isOn: $state.colorGradingEnabled) + .onChange(of: state.colorGradingEnabled) { _, _ in actions.applyColorGrading() } + slider("Exposure", $state.exposure, -4.0 ... 4.0, state.colorGradingEnabled, actions.applyColorGrading) + slider("Brightness", $state.brightness, -1.0 ... 1.0, state.colorGradingEnabled, actions.applyColorGrading) + slider("Contrast", $state.contrast, 0.0 ... 2.0, state.colorGradingEnabled, actions.applyColorGrading) + slider("Saturation", $state.saturation, 0.0 ... 2.0, state.colorGradingEnabled, actions.applyColorGrading) + slider("Temperature", $state.temperature, -1.0 ... 1.0, state.colorGradingEnabled, actions.applyColorGrading) + slider("Tint", $state.tint, -1.0 ... 1.0, state.colorGradingEnabled, actions.applyColorGrading) + + Divider() + section("SSAO") + Toggle("SSAO", isOn: $state.ssaoEnabled) + .onChange(of: state.ssaoEnabled) { _, _ in actions.applySSAO() } + Picker("Quality", selection: $state.ssaoQuality) { + ForEach(SSAOQualityOption.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .disabled(!state.ssaoEnabled) + .onChange(of: state.ssaoQuality) { _, _ in actions.applySSAO() } + slider("Radius", $state.ssaoRadius, 0.1 ... 2.0, state.ssaoEnabled, actions.applySSAO) + slider("Bias", $state.ssaoBias, 0.0 ... 0.1, state.ssaoEnabled, actions.applySSAO) + slider("Intensity", $state.ssaoIntensity, 0.0 ... 2.0, state.ssaoEnabled, actions.applySSAO) + + Divider() + section("Bloom") + Toggle("Bloom", isOn: $state.bloomEnabled) + .onChange(of: state.bloomEnabled) { _, _ in actions.applyBloom() } + slider("Threshold", $state.bloomThreshold, 0.0 ... 2.0, state.bloomEnabled, actions.applyBloom) + slider("Threshold Int.", $state.bloomThresholdIntensity, 0.0 ... 2.0, state.bloomEnabled, actions.applyBloom) + slider("Composite Int.", $state.bloomCompositeIntensity, 0.0 ... 2.0, state.bloomEnabled, actions.applyBloom) + + Divider() + section("Lens") + Toggle("Vignette", isOn: $state.vignetteEnabled) + .onChange(of: state.vignetteEnabled) { _, _ in actions.applyVignette() } + slider("Vignette Int.", $state.vignetteIntensity, 0.0 ... 1.0, state.vignetteEnabled, actions.applyVignette) + slider("Radius", $state.vignetteRadius, 0.0 ... 1.5, state.vignetteEnabled, actions.applyVignette) + slider("Softness", $state.vignetteSoftness, 0.0 ... 1.0, state.vignetteEnabled, actions.applyVignette) + + Toggle("Depth of Field", isOn: $state.depthOfFieldEnabled) + .onChange(of: state.depthOfFieldEnabled) { _, _ in actions.applyDepthOfField() } + slider("Focus Distance", $state.focusDistance, 0.5 ... 30.0, state.depthOfFieldEnabled, actions.applyDepthOfField) + slider("Focus Range", $state.focusRange, 0.1 ... 12.0, state.depthOfFieldEnabled, actions.applyDepthOfField) + slider("Max Blur", $state.maxBlur, 0.0 ... 16.0, state.depthOfFieldEnabled, actions.applyDepthOfField) + + Toggle("Chromatic Aberration", isOn: $state.chromaticAberrationEnabled) + .onChange(of: state.chromaticAberrationEnabled) { _, _ in actions.applyChromaticAberration() } + slider("Chromatic Int.", $state.chromaticAberrationIntensity, 0.0 ... 0.05, state.chromaticAberrationEnabled, actions.applyChromaticAberration) + + Text("Right-drag to orbit") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + } + + private func section(_ title: String) -> some View { + Text(title.uppercased()) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + + private func slider( + _ title: String, + _ value: Binding, + _ range: ClosedRange, + _ enabled: Bool, + _ onChange: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + Spacer() + Text(value.wrappedValue, format: .number.precision(.fractionLength(2))) + .monospacedDigit() + .foregroundStyle(.secondary) + } + Slider(value: value, in: range) + .onChange(of: value.wrappedValue) { _, _ in onChange() } + } + .font(.caption) + .opacity(enabled ? 1.0 : 0.35) + .disabled(!enabled) + } + } +#endif diff --git a/Sources/Demos/RenderingQualityDemo/GameScene.swift b/Sources/Demos/RenderingQualityDemo/GameScene.swift new file mode 100644 index 00000000..3b61a807 --- /dev/null +++ b/Sources/Demos/RenderingQualityDemo/GameScene.swift @@ -0,0 +1,254 @@ +// +// GameScene.swift +// RenderingQualityDemo +// + +#if os(macOS) + import Foundation + import simd + import UntoldEngine + + final class GameScene: @unchecked Sendable { + private enum Constants { + static let cameraEye = simd_float3(0.0, 5.5, 12.0) + static let cameraTarget = simd_float3(0.0, 0.7, 0.0) + static let orbitOffset: Float = 12.0 + } + + private var stadium: EntityID? + private var player: EntityID? + private var ball: EntityID? + private var wasRightMousePressed = false + + init() { + configureEngine() + createCamera() + createLights() + loadScene() + applyNeutralLook() + } + + func update(deltaTime _: Float) { + if gameMode == false { return } + } + + func handleInput() { + if gameMode == false { return } + if isSceneReady() == false { return } + + guard let camera = CameraSystem.shared.activeCamera else { return } + let input = InputSystem.shared + + if input.keyState.rightMousePressed { + if !wasRightMousePressed { + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitOffset) + } + orbitCameraAround(entityId: camera, uDelta: simd_float2(input.mouseDeltaX, input.mouseDeltaY)) + } + + wasRightMousePressed = input.keyState.rightMousePressed + } + + func setAntiAliasing(_ mode: AntiAliasingMode) { + setRendering(.antiAliasing(mode)) + } + + func setDebugView(_ mode: RenderDebugViewMode) { + if mode == .ssaoBlurred { + setPostFX(.ssao(.enabled(true))) + } + setRendering(.debugView(mode)) + } + + func applyNeutralLook() { + setRendering(.postProcessing(.enabled)) + setRendering(.debugView(.lit)) + setRendering(.antiAliasing(.fxaa)) + setPostFX(.preset(.neutral)) + setPostFX(.bloomThreshold(.enabled(false))) + setPostFX(.bloomComposite(.enabled(false))) + setPostFX(.vignette(.enabled(false))) + setPostFX(.chromaticAberration(.enabled(false))) + setPostFX(.depthOfField(.enabled(false))) + } + + func applyCinematicLook() { + setRendering(.postProcessing(.enabled)) + setRendering(.debugView(.lit)) + setRendering(.antiAliasing(.smaa)) + setPostFX(.preset(.cinematic)) + setPostFX(.bloomThreshold(.enabled(true))) + setPostFX(.bloomThreshold(.threshold(0.62))) + setPostFX(.bloomThreshold(.intensity(0.45))) + setPostFX(.bloomComposite(.enabled(true))) + setPostFX(.bloomComposite(.intensity(0.55))) + setPostFX(.vignette(.enabled(true))) + setPostFX(.vignette(.intensity(0.28))) + setPostFX(.vignette(.radius(0.82))) + setPostFX(.vignette(.softness(0.42))) + setPostFX(.chromaticAberration(.enabled(false))) + setPostFX(.depthOfField(.enabled(false))) + } + + func applyInspectionLook() { + setRendering(.postProcessing(.enabled)) + setRendering(.debugView(.lit)) + setRendering(.antiAliasing(.smaa)) + setPostFX(.preset(.archviz)) + setPostFX(.ssao(.enabled(true))) + setPostFX(.ssao(.quality(.high))) + setPostFX(.ssao(.radius(0.85))) + setPostFX(.ssao(.bias(0.02))) + setPostFX(.ssao(.intensity(0.7))) + setPostFX(.bloomThreshold(.enabled(false))) + setPostFX(.bloomComposite(.enabled(false))) + setPostFX(.vignette(.enabled(false))) + setPostFX(.chromaticAberration(.enabled(false))) + setPostFX(.depthOfField(.enabled(false))) + } + + func setColorGrading( + enabled: Bool, + exposure: Float, + brightness: Float, + contrast: Float, + saturation: Float, + temperature: Float, + tint: Float + ) { + setPostFX(.colorGrading(.enabled(enabled))) + setPostFX(.colorGrading(.exposure(exposure))) + setPostFX(.colorGrading(.brightness(brightness))) + setPostFX(.colorGrading(.contrast(contrast))) + setPostFX(.colorGrading(.saturation(saturation))) + setPostFX(.colorGrading(.temperature(temperature))) + setPostFX(.colorGrading(.tint(tint))) + } + + func setSSAO(enabled: Bool, radius: Float, bias: Float, intensity: Float, quality: SSAOQuality) { + setPostFX(.ssao(.enabled(enabled))) + setPostFX(.ssao(.quality(quality))) + setPostFX(.ssao(.radius(radius))) + setPostFX(.ssao(.bias(bias))) + setPostFX(.ssao(.intensity(intensity))) + } + + func setBloom(enabled: Bool, threshold: Float, thresholdIntensity: Float, compositeIntensity: Float) { + setPostFX(.bloomThreshold(.enabled(enabled))) + setPostFX(.bloomThreshold(.threshold(threshold))) + setPostFX(.bloomThreshold(.intensity(thresholdIntensity))) + setPostFX(.bloomComposite(.enabled(enabled))) + setPostFX(.bloomComposite(.intensity(compositeIntensity))) + } + + func setVignette(enabled: Bool, intensity: Float, radius: Float, softness: Float) { + setPostFX(.vignette(.enabled(enabled))) + setPostFX(.vignette(.intensity(intensity))) + setPostFX(.vignette(.radius(radius))) + setPostFX(.vignette(.softness(softness))) + } + + func setDepthOfField(enabled: Bool, focusDistance: Float, focusRange: Float, maxBlur: Float) { + setPostFX(.depthOfField(.enabled(enabled))) + setPostFX(.depthOfField(.focusDistance(focusDistance))) + setPostFX(.depthOfField(.focusRange(focusRange))) + setPostFX(.depthOfField(.maxBlur(maxBlur))) + } + + func setChromaticAberration(enabled: Bool, intensity: Float) { + setPostFX(.chromaticAberration(.enabled(enabled))) + setPostFX(.chromaticAberration(.intensity(intensity))) + setPostFX(.chromaticAberration(.center(simd_float2(0.5, 0.5)))) + } + + private func configureEngine() { + gameMode = true + setSceneReady(false) + setEngine(.assetBasePath(Self.resourcesURL())) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + InputSystem.shared.registerMouseEvents() + } + + private static func resourcesURL() -> URL { + let sourceURL = URL(fileURLWithPath: #filePath) + let repoRoot = sourceURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return repoRoot + .appendingPathComponent("Tests") + .appendingPathComponent("UntoldEngineRenderTests") + .appendingPathComponent("Resources") + } + + private func createCamera() { + let camera = createEntity() + setEntityName(entityId: camera, name: "Quality Camera") + createGameCamera(entityId: camera) + cameraLookAt( + entityId: camera, + eye: Constants.cameraEye, + target: Constants.cameraTarget, + up: simd_float3(0.0, 1.0, 0.0) + ) + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitOffset) + setCamera(.active(camera)) + } + + private func createLights() { + let sun = createEntity() + setEntityName(entityId: sun, name: "Key Light") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: -50.0, axis: simd_float3(1.0, 0.0, 0.0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.94, 0.86))) + setLight(entityId: sun, .intensity(1.55)) + setLight(entityId: sun, .directional(.active)) + + let fill = createEntity() + setEntityName(entityId: fill, name: "Fill Light") + createPointLight(entityId: fill) + translateTo(entityId: fill, position: simd_float3(-3.0, 2.0, 3.0)) + setLight(entityId: fill, .color(simd_float3(0.58, 0.70, 1.0))) + setLight(entityId: fill, .intensity(0.55)) + setLight(entityId: fill, .point(.radius(5.0))) + } + + private func loadScene() { + loadAsset("stadium") { [weak self] entity, success in + self?.stadium = entity + if success, let entity { + rotateTo(entityId: entity, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) + } + setSceneReady(success) + } + + loadAsset("redplayer") { [weak self] entity, success in + self?.player = entity + if success, let entity { + translateTo(entityId: entity, position: simd_float3(-1.1, 0.0, 0.4)) + } + } + + loadAsset("ball") { [weak self] entity, success in + self?.ball = entity + if success, let entity { + translateTo(entityId: entity, position: simd_float3(1.2, 0.45, -0.7)) + scaleTo(entityId: entity, scale: simd_float3(repeating: 0.75)) + } + } + } + + private func loadAsset( + _ name: String, + completion: @escaping @Sendable (EntityID?, Bool) -> Void + ) { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + setEntityMeshAsync(entityId: entity, filename: name, withExtension: "untold") { success in + completion(success ? entity : nil, success) + } + } + } +#endif diff --git a/Sources/Demos/RenderingQualityDemo/README.md b/Sources/Demos/RenderingQualityDemo/README.md new file mode 100644 index 00000000..d1295dfa --- /dev/null +++ b/Sources/Demos/RenderingQualityDemo/README.md @@ -0,0 +1,22 @@ +# Rendering Quality Demo + +Focused demo for Untold Engine rendering and post-processing controls. + +Run it from the repository root: + +```bash +swift run renderingqualitydemo +``` + +What it demonstrates: + +- anti-aliasing modes through `setRendering(.antiAliasing(...))` +- render debug views through `setRendering(.debugView(...))` +- PostFX presets through `setPostFX(.preset(...))` +- interactive sliders for color grading, SSAO, bloom, vignette, depth of field, and chromatic aberration +- a small textured scene using assets from `Tests/UntoldEngineRenderTests/Resources` + +Controls: + +- use the on-screen panel to switch quality settings +- right-drag orbits the camera diff --git a/Sources/Demos/RenderingQualityDemo/main.swift b/Sources/Demos/RenderingQualityDemo/main.swift new file mode 100644 index 00000000..e3181f43 --- /dev/null +++ b/Sources/Demos/RenderingQualityDemo/main.swift @@ -0,0 +1,13 @@ +// +// main.swift +// RenderingQualityDemo +// + +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif diff --git a/Sources/DemoGame/AppDelegate.swift b/Sources/Demos/ShowcaseDemo/AppDelegate.swift similarity index 99% rename from Sources/DemoGame/AppDelegate.swift rename to Sources/Demos/ShowcaseDemo/AppDelegate.swift index 7b949964..245695bd 100644 --- a/Sources/DemoGame/AppDelegate.swift +++ b/Sources/Demos/ShowcaseDemo/AppDelegate.swift @@ -118,7 +118,7 @@ private func printUsage() { print(""" - Usage: swift run untolddemo [--resolution WIDTHxHEIGHT] + Usage: swift run showcasedemo [--resolution WIDTHxHEIGHT] Options: --resolution WIDTHxHEIGHT Start the demo at a specific window size, for example 800x600. diff --git a/Sources/DemoGame/DemoHUD.swift b/Sources/Demos/ShowcaseDemo/DemoHUD.swift similarity index 100% rename from Sources/DemoGame/DemoHUD.swift rename to Sources/Demos/ShowcaseDemo/DemoHUD.swift diff --git a/Sources/DemoGame/DemoState.swift b/Sources/Demos/ShowcaseDemo/DemoState.swift similarity index 100% rename from Sources/DemoGame/DemoState.swift rename to Sources/Demos/ShowcaseDemo/DemoState.swift diff --git a/Sources/DemoGame/GameScene.swift b/Sources/Demos/ShowcaseDemo/GameScene.swift similarity index 100% rename from Sources/DemoGame/GameScene.swift rename to Sources/Demos/ShowcaseDemo/GameScene.swift diff --git a/Sources/DemoGame/main.swift b/Sources/Demos/ShowcaseDemo/main.swift similarity index 91% rename from Sources/DemoGame/main.swift rename to Sources/Demos/ShowcaseDemo/main.swift index 45574d12..549ff60f 100644 --- a/Sources/DemoGame/main.swift +++ b/Sources/Demos/ShowcaseDemo/main.swift @@ -8,7 +8,7 @@ let executableName = URL(fileURLWithPath: CommandLine.arguments.first ?? "").lastPathComponent if executableName == "DemoGame" { - fputs("[DEPRECATED] DemoGame is deprecated. Please use: swift run untolddemo\n", stderr) + fputs("[DEPRECATED] DemoGame is deprecated. Please use: swift run showcasedemo\n", stderr) } let app = NSApplication.shared diff --git a/Sources/Demos/StarterDemo/AppDelegate.swift b/Sources/Demos/StarterDemo/AppDelegate.swift new file mode 100644 index 00000000..2daa6a34 --- /dev/null +++ b/Sources/Demos/StarterDemo/AppDelegate.swift @@ -0,0 +1,95 @@ +// +// AppDelegate.swift +// StarterDemo +// + +#if os(macOS) + import AppKit + import SwiftUI + import UntoldEngine + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1280, height: 720) + static let minimumWindowSize = NSSize(width: 800, height: 600) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var gameScene: GameScene! + + func applicationDidFinishLaunching(_: Notification) { + setupWindow() + setupRendererAndScene() + presentSceneView() + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + private func setupWindow() { + window = NSWindow( + contentRect: NSRect(origin: .zero, size: Constants.windowSize), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Untold Engine Starter Demo" + window.minSize = Constants.minimumWindowSize + window.center() + } + + private func setupRendererAndScene() { + guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return + } + + self.renderer = renderer + gameScene = GameScene() + + renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.gameScene.update(deltaTime: deltaTime) + }, + handleInput: { [weak self] in + self?.gameScene.handleInput() + } + ) + } + + private func presentSceneView() { + guard let renderer else { return } + + let hostingView = NSHostingView(rootView: StarterDemoView(renderer: renderer)) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + } + + private struct StarterDemoView: View { + let renderer: UntoldRenderer + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer) + + VStack(alignment: .leading, spacing: 6) { + Text("Starter Demo") + .font(.headline) + Text("WASD move | Q/E up-down | Right-drag orbit") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(16) + } + } + } +#endif diff --git a/Sources/Demos/StarterDemo/GameScene.swift b/Sources/Demos/StarterDemo/GameScene.swift new file mode 100644 index 00000000..d53f8ba6 --- /dev/null +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -0,0 +1,140 @@ +// +// GameScene.swift +// StarterDemo +// + +#if os(macOS) + import Foundation + import simd + import SwiftUI + import UntoldEngine + + final class GameScene { + private enum Constants { + static let cameraMoveSpeed: Float = 4.0 + static let orbitTargetOffset: Float = 5.0 + static let cameraStart = simd_float3(0.0, 2.0, 6.0) + static let worldOrigin = simd_float3(0.0, 0.0, 0.0) + } + + private var cube: EntityID? + private var wasRightMousePressed = false + + init() { + configureEngine() + createCamera() + createLight() + createStarterObject() + setSceneReady(false) + } + + func update(deltaTime: Float) { + guard let cube, gameMode else { return } + + rotateBy( + entityId: cube, + angle: 25.0 * deltaTime, + axis: simd_float3(0.0, 1.0, 0.0) + ) + + setSceneReady(true) + } + + func handleInput() { + guard gameMode, isSceneReady() else { return } + guard let camera = CameraSystem.shared.activeCamera else { return } + + let input = InputSystem.shared + moveCameraWithInput( + entityId: camera, + input: ( + w: input.keyState.wPressed, + a: input.keyState.aPressed, + s: input.keyState.sPressed, + d: input.keyState.dPressed, + q: input.keyState.qPressed, + e: input.keyState.ePressed + ), + speed: Constants.cameraMoveSpeed, + deltaTime: 1.0 / 60.0 + ) + + if input.keyState.rightMousePressed { + if !wasRightMousePressed { + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) + } + orbitCameraAround( + entityId: camera, + uDelta: simd_float2(input.mouseDeltaX, input.mouseDeltaY) + ) + } + + wasRightMousePressed = input.keyState.rightMousePressed + } + + private func configureEngine() { + gameMode = true + setSceneReady(false) + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.fxaa)) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + + InputSystem.shared.registerKeyboardEvents() + InputSystem.shared.registerMouseEvents() + } + + private func createCamera() { + let camera = createEntity() + setEntityName(entityId: camera, name: "Main Camera") + createGameCamera(entityId: camera) + cameraLookAt( + entityId: camera, + eye: Constants.cameraStart, + target: Constants.worldOrigin, + up: simd_float3(0.0, 1.0, 0.0) + ) + setOrbitOffset(entityId: camera, uTargetOffset: Constants.orbitTargetOffset) + setCamera(.active(camera)) + } + + private func createLight() { + let sun = createEntity() + setEntityName(entityId: sun, name: "Key Light") + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: -45.0, axis: simd_float3(1.0, 0.0, 0.0)) + setLight(entityId: sun, .color(simd_float3(1.0, 0.92, 0.82))) + setLight(entityId: sun, .intensity(1.4)) + setLight(entityId: sun, .directional(.active)) + } + + private func createStarterObject() { + let entity = createEntity() + setEntityName(entityId: entity, name: "Starter Cube") + setEntityMeshDirect( + entityId: entity, + meshes: BasicPrimitives.createCube(extent: 1.25), + assetName: "starter_cube" + ) + translateTo(entityId: entity, position: Constants.worldOrigin) + updateMaterialColor(entityId: entity, color: Color(red: 0.95, green: 0.42, blue: 0.18)) + cube = entity + setSceneReady(true) + } + + // Use this instead of createStarterObject() when you add your own exported asset: + // + // private func loadUntoldAsset() { + // let entity = createEntity() + // setEntityName(entityId: entity, name: "My Model") + // setEntityMeshAsync(entityId: entity, filename: "my_model", withExtension: "untold") { success in + // guard success else { + // setSceneReady(false) + // return + // } + // translateTo(entityId: entity, position: .zero) + // setSceneReady(true) + // } + // } + } +#endif diff --git a/Sources/Demos/StarterDemo/README.md b/Sources/Demos/StarterDemo/README.md new file mode 100644 index 00000000..e4a5f990 --- /dev/null +++ b/Sources/Demos/StarterDemo/README.md @@ -0,0 +1,23 @@ +# Starter Demo + +Minimal first-run demo for Untold Engine. + +Run it from the repository root: + +```bash +swift run starterdemo +``` + +What it demonstrates: + +- create a renderer-backed macOS window +- create a game camera and directional light +- create one renderable entity +- move the camera with `WASD` and `Q/E` +- orbit with right mouse drag +- update an entity every frame + +The demo uses `BasicPrimitives.createCube()` so it runs without external assets. +To try your own content, export a model to `.untold` and replace +`createStarterObject()` in `GameScene.swift` with the commented +`setEntityMeshAsync(...)` example. diff --git a/Sources/Demos/StarterDemo/main.swift b/Sources/Demos/StarterDemo/main.swift new file mode 100644 index 00000000..482d749e --- /dev/null +++ b/Sources/Demos/StarterDemo/main.swift @@ -0,0 +1,13 @@ +// +// main.swift +// StarterDemo +// + +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif diff --git a/Tests/UntoldEngineRenderTests/Resources/Animations/idle/idle.untold b/Tests/UntoldEngineRenderTests/Resources/Animations/idle/idle.untold index bf4c8bf0..eacb6f17 100644 Binary files a/Tests/UntoldEngineRenderTests/Resources/Animations/idle/idle.untold and b/Tests/UntoldEngineRenderTests/Resources/Animations/idle/idle.untold differ diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index 00f99aaf..44513a21 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -184,7 +184,7 @@ setEntityName(entityId: entity, name: "robot") setEntityMeshAsync(entityId: entity, filename: "robot", withExtension: "untold") { success in if success { - translateBy(entityId: entity, position: simd_float3(0.0, 0.0, 0.0)) + translateTo(entityId: entity, position: simd_float3(0.0, 0.0, 0.0)) setEntityKinetics(entityId: entity) } setSceneReady(success) @@ -260,7 +260,7 @@ if let url = URL(string: "https://cdn.example.com/dungeon/dungeon.json") { `setEntityStreamScene` registers lightweight stub entities for every tile in the manifest, all parented under `sceneRoot` (no geometry is parsed at this point). `GeometryStreamingSystem` then loads and unloads tile geometry as the camera moves. -See [Tile-Based Streaming](../Architecture/tilebasedstreaming) for the full streaming +See [Tile-Based Streaming](../Architecture/tilebasedstreaming.md) for the full streaming architecture. > **Legacy overloads** โ€” `loadTiledScene(manifest:)` and `loadTiledScene(url:)` remain diff --git a/docs/API/UsingAnimationSystem.md b/docs/API/UsingAnimationSystem.md index c8b6c5ce..0f8b254a 100644 --- a/docs/API/UsingAnimationSystem.md +++ b/docs/API/UsingAnimationSystem.md @@ -32,6 +32,8 @@ Load the animation data for your model by providing the exported animation `.unt setEntityAnimations(entityId: redPlayer, filename: "running", withExtension: "untold", name: "running") ``` +For hierarchical or multi-mesh `.untold` assets, call animation APIs on the asset root. The engine resolves the root to every skinned render descendant and installs the clip on each target that has both a `SkeletonComponent` and a `RenderComponent`. This keeps split characters or multi-part rigged models animated as one actor. + --- ### Step 4: Set the Animation to play @@ -42,6 +44,8 @@ Trigger the animation by referencing its name. This will set the animation to pl changeAnimation(entityId: redPlayer, name: "running") ``` +`changeAnimation(entityId:name:withPause:)`, `pauseAnimationComponent(entityId:isPaused:)`, `setAnimationPlaybackSpeed(entityId:speed:)`, and `removeAnimationClip(entityId:animationClip:)` all operate on the entity and any descendant animation components. `getAllAnimationClips(entityId:)` returns the union of clip names found under the entity. `getAnimationPlaybackSpeed(entityId:)` returns the speed from the first resolved animation component. + --- ### Step 5. Pause the animation (Optional) @@ -67,5 +71,6 @@ Once the animation is set up: ## Tips and Best Practices - Name Animations Clearly: Use descriptive names like "running" or "jumping" to make it easier to manage multiple animations. +- For split rigged assets, treat the load root as the public animation handle; do not manually chase child mesh entities unless you need custom per-part behavior. - Debug Orientation Issues: If the modelโ€™s animation appears misaligned, revisit the flip parameter or check the modelโ€™s export settings. - Combine Animations: For complex behaviors, load multiple animations (e.g., walking, idle, jumping) and switch between them dynamically. diff --git a/docs/API/UsingGeometryStreamingSystem.md b/docs/API/UsingGeometryStreamingSystem.md index 86737812..9454f3a3 100644 --- a/docs/API/UsingGeometryStreamingSystem.md +++ b/docs/API/UsingGeometryStreamingSystem.md @@ -12,7 +12,7 @@ The public rule is simple: `GeometryStreamingSystem` manages the runtime once a streamed scene is loaded. It is not a public component-authoring workflow for standalone entities. -> For handcrafted zone streaming without a manifest (e.g. dungeon rooms, level sectors), use `StreamingRegionManager.shared`. See the [StreamingRegionManager architecture doc](../Architecture/streamingRegionManager) for the full API. +> For handcrafted zone streaming without a manifest (e.g. dungeon rooms, level sectors), use `StreamingRegionManager.shared`. See the [StreamingRegionManager architecture doc](../Architecture/streamingRegionManager.md) for the full API. ## Public Workflow @@ -223,7 +223,7 @@ The rule of thumb: **call it whenever you know a new tile-streaming session is a ## Related Docs -- [Tile-Based Streaming](../Architecture/tilebasedstreaming) -- [Geometry Streaming Architecture](../Architecture/geometryStreamingSystem) -- [Texture Streaming](../Architecture/textureStreamingSystem) -- [Remote Asset Streaming](../Architecture/asset_remote_streaming) +- [Tile-Based Streaming](../Architecture/tilebasedstreaming.md) +- [Geometry Streaming Architecture](../Architecture/geometryStreamingSystem.md) +- [Texture Streaming](../Architecture/textureStreamingSystem.md) +- [Remote Asset Streaming](../Architecture/asset_remote_streaming.md) diff --git a/docs/API/UsingInputSystem.md b/docs/API/UsingInputSystem.md index 2c2f0bd9..c84c42dc 100644 --- a/docs/API/UsingInputSystem.md +++ b/docs/API/UsingInputSystem.md @@ -49,7 +49,20 @@ if inputSystem.keyState.dPressed == true { } ``` -###Step 2: Using Input to Control Entities +### Available Key State Fields + +`KeyState` currently exposes: + +| Group | Fields | +|---|---| +| Movement/common letters | `wPressed`, `aPressed`, `sPressed`, `dPressed`, `qPressed`, `ePressed`, `fPressed`, `hPressed`, `jPressed`, `kPressed`, `lPressed` | +| Function keys | `f1Pressed` through `f12Pressed` | +| Navigation/modifier keys | `tabPressed`, `spacePressed`, `shiftPressed`, `ctrlPressed`, `altPressed` | +| Mouse buttons | `leftMousePressed`, `rightMousePressed`, `middleMousePressed` | + +On macOS, keyboard events are ignored while an `NSText` field is focused, so typing into editor text controls does not leak into game input. Modifier flags update from system flag-change events. + +### Step 2: Using Input to Control Entities Here's an example function that moves a car entity based on keyboard inputs: diff --git a/docs/API/UsingLOD-Batching-Streaming.md b/docs/API/UsingLOD-Batching-Streaming.md index 3dfd6da0..dd303f01 100644 --- a/docs/API/UsingLOD-Batching-Streaming.md +++ b/docs/API/UsingLOD-Batching-Streaming.md @@ -129,4 +129,4 @@ For a full description of every tuning parameter, see [Batching System Architect - [Geometry Streaming System](./UsingGeometryStreamingSystem.md) - [Static Batching System](./UsingStaticBatchingSystem.md) - [LOD System](./UsingLODSystem.md) -- [Tile-Based Streaming Architecture](../Architecture/tilebasedstreaming) +- [Tile-Based Streaming Architecture](../Architecture/tilebasedstreaming.md) diff --git a/docs/API/UsingPhysicsSystem.md b/docs/API/UsingPhysicsSystem.md index 2c48ac8c..4e550a1e 100644 --- a/docs/API/UsingPhysicsSystem.md +++ b/docs/API/UsingPhysicsSystem.md @@ -53,12 +53,12 @@ applyForce(entityId: redPlayer, force: simd_float3(0.0, 0.0, 5.0)) --- #### Step 6: Use the Steering System -For advanced movement behaviors, leverage the Steering System to steer entities toward or away from targets. This system automatically calculates the required forces. +For advanced movement behaviors, use the Steering System helpers to steer entities toward or away from targets. The high-level helpers calculate steering forces and apply them through the physics system. Example: Steering Toward a Position ```swift -steerTo(entityId: redPlayer, targetPosition: simd_float3(0.0, 0.0, 5.0), maxSpeed: 2.0, deltaTime: deltaTime) +steerSeek(entityId: redPlayer, targetPosition: simd_float3(0.0, 0.0, 5.0), maxSpeed: 2.0, deltaTime: deltaTime) ``` --- @@ -67,9 +67,11 @@ steerTo(entityId: redPlayer, targetPosition: simd_float3(0.0, 0.0, 5.0), maxSpee The Steering System includes other useful behaviors, such as: -- steerAway() -- steerPursuit() -- followPath() +- `steerFlee(entityId:threatPosition:maxSpeed:deltaTime:)` +- `steerArrive(entityId:targetPosition:maxSpeed:slowingRadius:deltaTime:)` +- `steerPursuit(entityId:targetEntity:maxSpeed:deltaTime:)` +- `steerFollowPath(entityId:path:maxSpeed:deltaTime:)` +- `steerAvoidObstacles(entityId:obstacles:avoidanceRadius:maxSpeed:deltaTime:)` These functions simplify complex movement patterns, making them easy to implement. diff --git a/docs/API/UsingProfiler.md b/docs/API/UsingProfiler.md index c9506610..13c8e590 100644 --- a/docs/API/UsingProfiler.md +++ b/docs/API/UsingProfiler.md @@ -34,7 +34,7 @@ setEngineStatsLogging( ### Verbose output format -With `profile: .verbose`, the engine logs 8 lines every interval: +With `profile: .verbose`, the engine logs a multi-line snapshot every interval: ``` Frame 1234 | CPU 12.34ms (81.0 fps, smoothed) GPU 8.45ms exec / 90.0 fps cadence [GPU-bound] diff --git a/docs/API/UsingStaticBatchingSystem.md b/docs/API/UsingStaticBatchingSystem.md index 3c613701..63d88e97 100644 --- a/docs/API/UsingStaticBatchingSystem.md +++ b/docs/API/UsingStaticBatchingSystem.md @@ -54,9 +54,8 @@ setEntityStreamScene(entityId: sceneRoot, manifest: "city", withExtension: "json In this mode: -- `registerTiledScene(...)` enables batching automatically -- full-load tiles notify batching through `notifyTileEntitiesResident(_:)` -- OCC sub-mesh uploads join batching incrementally through normal residency events +- `setEntityStreamScene(...)` registers tiled-scene entities with batching automatically +- full-load tiles and OCC sub-mesh uploads notify batching through internal residency events - per-tile LOD and HLOD representations can also participate when enabled You do **not** call `generateBatches()` every time a tile loads. The batching system rebuilds dirty cells incrementally based on residency changes. @@ -76,12 +75,12 @@ setEntityStreamScene(entityId: sceneRoot, manifest: "city", withExtension: "json } ``` -For non-streamed scenes (single `.untold`), call `setEntityStaticBatchComponent`, `generateBatches()`, and `setBatching(.enabled(true))` as normal. The same applies to any operation that mutates material state (color, opacity) โ€” wrap it with `setBatching(.enabled(false))` before and `generateBatches()` + `setBatching(.enabled(true))` after, but only for non-streamed scenes: +For non-streamed scenes (single `.untold`), call `setEntityStaticBatchComponent`, `generateBatches()`, and `setBatching(.enabled(true))` as normal. Material updates such as opacity automatically notify batching, but if you want a deterministic manual rebuild in an always-resident scene, wrap the edit with `setBatching(.enabled(false))` before and `generateBatches()` + `setBatching(.enabled(true))` after. Do not use this pattern in streamed scenes: ```swift // Non-streamed only โ€” do not use this pattern in tiled/streamed scenes setBatching(.enabled(false)) -setEntityColor(entityId: prop, color: simd_float4(1, 0, 0, 1)) +updateMaterialOpacity(entityId: prop, opacity: 0.5) generateBatches() setBatching(.enabled(true)) ``` @@ -154,4 +153,4 @@ clearSceneBatches() - `TileLODTagComponent` lets batching treat per-tile LODs and HLODs as distinct LOD groups even though they are not entity-level `LODComponent` assets. - Scene channels separate context geometry from selectable geometry. Entities marked `.preserveIdentity` are excluded from batching, and batch groups are separated by channel mask so channel visibility can be toggled without rebuilding batches. -For architectural details, see [Batching System](../Architecture/batchingSystem). +For architectural details, see [Batching System](../Architecture/batchingSystem.md). diff --git a/docs/API/UsingSteeringSystem.md b/docs/API/UsingSteeringSystem.md index 96584b05..6decf424 100644 --- a/docs/API/UsingSteeringSystem.md +++ b/docs/API/UsingSteeringSystem.md @@ -1,6 +1,6 @@ # Using the Steering System in Untold Engine -The Steering System in the Untold Engine enables entities to move dynamically and intelligently within the scene. It provides both low-level steering behaviors (e.g., seek, flee, arrive) for granular control and high-level behaviors (e.g., steerTo, steerAway, followPath) that integrate seamlessly with the Physics System. +The Steering System in the Untold Engine enables entities to move dynamically and intelligently within the scene. It provides both low-level steering behaviors (`flee`, `arrive`, `pursuit`, `evade`) for granular control and high-level behaviors (`steerSeek`, `steerFlee`, `steerArrive`, `steerFollowPath`) that integrate with the Physics System. ## Why Use the Steering System? @@ -49,9 +49,11 @@ steerAvoidObstacles(entityId: entity, obstacles: obstacleEntities, avoidanceRadi 6. Steer Toward a Target Position (with Arrive): ```swift -steerArrive(entityId: entity, targetPosition: targetPosition, maxSpeed: 5.0, deltaTime: 0.016) +steerArrive(entityId: entity, targetPosition: targetPosition, maxSpeed: 5.0, slowingRadius: 2.0, deltaTime: 0.016) ``` +`slowingRadius` controls how early the entity starts braking as it approaches the target. + 7. Steer using WASD keys ```swift @@ -98,4 +100,3 @@ steerWithWASD(entityId: entity, maxSpeed: 5.0, deltaTime: 0.016) - Cause: Avoidance radius is too small or obstacles are not registered. - Solution: Increase the avoidanceRadius and verify obstacle entities. - diff --git a/docs/API/UsingTheLogger.md b/docs/API/UsingTheLogger.md index a47885ed..fb11dd77 100644 --- a/docs/API/UsingTheLogger.md +++ b/docs/API/UsingTheLogger.md @@ -43,7 +43,7 @@ Console output is prefixed with `Log:`. Logger.logWarning(message: "Mesh has no UV channel", category: LogCategory.general.rawValue) ``` -Requires `logLevel >= .warning`. Always emits regardless of category state. +Requires `logLevel >= .warning` and an enabled category. Console output is prefixed with `Warning:`. @@ -213,5 +213,6 @@ Sinks are held weakly โ€” the logger will not extend their lifetime. ## Category Toggle Notes - `Logger.log(...)` respects both `logLevel` and category state. -- `Logger.logWarning(...)` and `Logger.logError(...)` respect `logLevel` only โ€” they are never suppressed by category. +- `Logger.logWarning(...)` respects both `logLevel` and category state. +- `Logger.logError(...)` respects `logLevel` only and is not suppressed by category. - Category overrides layer on top of the built-in defaults. Call `setLogger(.resetCategories)` to restore defaults without restarting. diff --git a/docs/Architecture/asset_remote_streaming.md b/docs/Architecture/asset_remote_streaming.md index 2022ba96..aee77ebc 100644 --- a/docs/Architecture/asset_remote_streaming.md +++ b/docs/Architecture/asset_remote_streaming.md @@ -269,9 +269,9 @@ For a typical scene with `streamingRadius = 80 m` and `unloadRadius = 120 m`, `e --- -## Demo Game Configuration +## Showcase Demo Configuration -`Sources/DemoGame/DemoState.swift` registers two remote scenes: +`Sources/Demos/ShowcaseDemo/DemoState.swift` registers two remote scenes: ```swift let remoteScenes: [RemoteSceneOption] = [ diff --git a/docs/Architecture/geometryStreamingSystem.md b/docs/Architecture/geometryStreamingSystem.md index 41ed129f..0f5c7cd2 100644 --- a/docs/Architecture/geometryStreamingSystem.md +++ b/docs/Architecture/geometryStreamingSystem.md @@ -173,7 +173,7 @@ if geometry pressure is high: If `evictLRU` cannot clear geometry pressure, the system runs `evictTileGeometry(...)` as a second-stage pass. This reaches representations that do not live in `loadedStreamingEntities`: full-load tiles, HLODs, and per-tile LODs. It evicts farthest first, protects tiles inside their own `streamingRadius`, and does not evict parsed full tiles until `minimumParsedTileResidentSeconds` has elapsed. -For explicit session transitions, call `GeometryStreamingSystem.shared.forceUnloadAllParsedTiles()` instead โ€” see [`tilebasedstreaming.md`](tilebasedstreaming.md#forceunloadallparsedtiles----explicit-session-transition). +For explicit session transitions, call `GeometryStreamingSystem.shared.forceUnloadAllParsedTiles()` instead; see [`tilebasedstreaming.md`](tilebasedstreaming.md). The `sizeFactor` in the eviction score is normalized against `geometryBudget` (not the combined budget), so a mesh consuming 80% of the geometry pool scores correctly rather than appearing to consume only ~48% of a combined total. diff --git a/docs/Architecture/sceneChannels.md b/docs/Architecture/sceneChannels.md index 5605e882..22a0b12f 100644 --- a/docs/Architecture/sceneChannels.md +++ b/docs/Architecture/sceneChannels.md @@ -76,6 +76,29 @@ For `.untold` assets, the exporter writes optional architectural edge index buff `WireframeRenderParams` controls visual density. `distanceFadeEnabled`, `fadeStartDistance`, `fadeEndDistance`, and `minimumAlpha` reduce line opacity for distant geometry without changing the scene-channel API. The fragment shader uses `color.a` as the near opacity and fades to `color.a * minimumAlpha` between `fadeStartDistance` and `fadeEndDistance`. +## Light Portals + +Scene channels also own light portal configuration: + +```swift +setSceneChannel( + .userCustom(index: 1), + .lightPortal(.enabled( + intensity: 1.0, + range: 6.0, + useRealWorldTint: true, + maxActivePortals: 8, + activationDistance: 15.0 + )) +) +``` + +`LightPortalSystem` discovers visible render entities whose channel mask resolves to an enabled light-portal mode. Discovery rejects hidden entities, invisible render components, disabled portal channels, and geometry whose local bounds are not thin enough to infer a portal plane. Resolution then filters candidates by camera distance and emits the nearest proxy area lights up to the channel cap. + +The renderer does not create persistent ECS light entities for portals. Instead, each active portal becomes a temporary area-light entry for the current frame. Authored area lights keep priority, and portal proxy lights fill only the remaining area-light capacity. When `useRealWorldTint` is enabled, portal intensity and tint come from the current XR environment lighting estimate and `realWorldLightingContribution`; if XR real-world lighting is requested but invalid, real-world-tinted portals intentionally emit at zero. + +Portal discovery, resolution, performance, and render diagnostics are exposed through the light portal API and can be logged through the `.lightPortal` logger category. + ## Picking `SceneChannelInteractionState` (in `SceneContextVisibility.swift`) tracks a bitmask of channels with picking disabled via: diff --git a/docs/Architecture/xrRenderingSystem.md b/docs/Architecture/xrRenderingSystem.md index 41956454..9e2efdfb 100644 --- a/docs/Architecture/xrRenderingSystem.md +++ b/docs/Architecture/xrRenderingSystem.md @@ -24,6 +24,8 @@ At init time, three things happen in parallel: **ARKit session startup** (async Task): Queries world sensing authorization, then launches `WorldTrackingProvider` and `PlaneDetectionProvider`. World tracking is what gives you the device anchor โ€” the head pose needed to render correctly in the user's space. If world sensing is denied (e.g., the user blocked it in Settings), the engine still runs with world tracking only so rendering doesn't break, it just has no plane data. +**Environment lighting provider ownership**: The XR layer observes the runtime rendering lighting mode. When the app sets `setRendering(.environment(.lightingMode(.realWorldEstimate)))`, the XR runtime includes ARKit environment light estimation in the provider set and feeds accepted probe updates into the engine's IBL path. Switching back to `.authoredOnly` or `.staticIBL` disables that provider path. `realWorldLightingContribution` is a rendering multiplier and can change at runtime without restarting ARKit providers. + **Plane monitor** (background Task): A long-running Swift structured concurrency task that consumes the `planeDetection.anchorUpdates` async stream. Every time the system detects, updates, or removes a real-world surface (floor, wall, table, etc.), it maps the ARKit classification to the engine's `RealSurfaceKind` enum and forwards it to `RealSurfacePlaneStore`. Game code queries this store to snap objects to real surfaces. **Renderer creation**: `UntoldRenderer.createXR(...)` initializes the Metal device, command queue, G-Buffer textures, pipeline states, and all other GPU resources at the fixed visionOS viewport size (2048 ร— 1984 per eye). @@ -79,6 +81,8 @@ Everything between `startUpdate` and `endUpdate` is CPU-side frame preparation: - **Game update**: `renderer.updateXR()` calls the user's `gameUpdate` and `handleInput` callbacks. This is where game logic runs โ€” entity movement, animation state machines, physics steps. It is skipped entirely while `AssetLoadingGate.shared.isLoadingAny` is true. +- **XR lighting probe processing**: When real-world lighting mode is active, accepted environment-light probe updates are prefiltered into runtime IBL textures. The renderer uses the latest valid probe plus `realWorldLightingContribution`; if no valid probe is available, it falls back to the configured static environment path. + ### 2c. Wait for Optimal Input Time ```swift @@ -255,3 +259,4 @@ The snapshot is processed on the next frame's update phase by `spatialGestureRec | HZB build | After the single render graph | After both eyes, once | | Base pass | Environment or grid | Environment (full immersion) or none (mixed/passthrough) | | Game update thread | Main thread (MTKView delegate) | Compositor thread, with main-thread dispatch for restricted APIs | +| Environment lighting | Static/authored settings | Runtime mode can own ARKit environment-light provider lifecycle | diff --git a/mkdocs.yml b/mkdocs.yml index 190360e3..7c145abc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - API: - Getting Started: API/GettingStarted.md - Usage Examples: API/UsageExamples.md + - Engine Settings: API/UsingEngineSettings.md - Registration System: API/UsingRegistrationSystem.md - Rendering System: API/UsingRenderingSystem.md - Transform System: API/UsingTransformSystem.md @@ -53,6 +54,9 @@ nav: - Input System: API/UsingInputSystem.md - Spatial Input: API/UsingSpatialInput.md - Lighting System: API/UsingLightingSystem.md + - XR Lighting: API/UsingXRLighting.md + - Scene Channels: API/UsingSceneChannels.md + - Light Portals: API/UsingLightPortals.md - Materials: API/UsingMaterials.md - Animation System: API/UsingAnimationSystem.md - Physics System: API/UsingPhysicsSystem.md @@ -75,6 +79,7 @@ nav: - Architecture: - Rendering System: Architecture/renderingSystem.md - XR Rendering System: Architecture/xrRenderingSystem.md + - Scene Channels: Architecture/sceneChannels.md - Out-of-Core Geometry: Architecture/outOfCore.md - Batching System: Architecture/batchingSystem.md - LOD System: Architecture/lodSystem.md diff --git a/scripts/next-version.sh b/scripts/next-version.sh index b8a3f446..c1705cb4 100755 --- a/scripts/next-version.sh +++ b/scripts/next-version.sh @@ -118,11 +118,11 @@ if [[ "${DO_CLIFF}" == "true" ]]; then RANGE="${BASE_REF}..HEAD" git cliff "${RANGE}" --tag "${TAG}" --prepend CHANGELOG.md - # Update appVersion in DemoGame and Sandbox + # Update appVersion in ShowcaseDemo and Sandbox sed -i '' 's/static let appVersion = "[^"]*"/static let appVersion = "'"${NEXT}"'"/' \ - Sources/DemoGame/AppDelegate.swift \ + Sources/Demos/ShowcaseDemo/AppDelegate.swift \ Sources/Sandbox/AppDelegate.swift - echo "Updated appVersion to ${NEXT} in DemoGame and Sandbox." + echo "Updated appVersion to ${NEXT} in ShowcaseDemo and Sandbox." # Update engine startup log in UntoldEngine sed -i '' 's/Logger\.log(message: "Untold Engine Starting[^"]*")/Logger.log(message: "Untold Engine Starting. Version '"${NEXT}"'")/' \