From 591b426a6487e0547a857251bcba4faf15ca8ca7 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 25 Jun 2026 23:11:14 -0700 Subject: [PATCH 1/4] [Docs] Updated architecture documentation --- docs/API/GettingStarted.md | 4 ++-- docs/API/UsingAnimationSystem.md | 5 +++++ docs/API/UsingGeometryStreamingSystem.md | 10 ++++----- docs/API/UsingInputSystem.md | 15 ++++++++++++- docs/API/UsingLOD-Batching-Streaming.md | 2 +- docs/API/UsingPhysicsSystem.md | 12 +++++----- docs/API/UsingProfiler.md | 2 +- docs/API/UsingStaticBatchingSystem.md | 11 +++++----- docs/API/UsingSteeringSystem.md | 7 +++--- docs/API/UsingTheLogger.md | 5 +++-- docs/Architecture/geometryStreamingSystem.md | 2 +- docs/Architecture/sceneChannels.md | 23 ++++++++++++++++++++ docs/Architecture/xrRenderingSystem.md | 5 +++++ mkdocs.yml | 5 +++++ 14 files changed, 81 insertions(+), 27 deletions(-) diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index 00f99aaf9..44513a213 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 c8b6c5ced..0f8b254a7 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 86737812c..9454f3a33 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 2c2f0bd94..c84c42dc2 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 3dfd6da00..dd303f01a 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 2c48ac8cc..4e550a1e3 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 c9506610c..13c8e5909 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 3c613701e..63d88e977 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 96584b054..6decf4248 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 a47885edb..fb11dd77e 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/geometryStreamingSystem.md b/docs/Architecture/geometryStreamingSystem.md index 41ed129fd..0f5c7cd24 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 5605e8821..22a0b12fb 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 419564547..9e2efdfbe 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 190360e39..7c145abce 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 From 2e12e3a88558ff56534d9833eb6de3af884507a6 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 25 Jun 2026 23:12:22 -0700 Subject: [PATCH 2/4] [Demo] Updated the demo --- Package.swift | 101 +++- README.md | 22 +- .../ExporterPipelineDemo/AppDelegate.swift | 200 ++++++++ .../ExporterPipelineDemo/GameScene.swift | 296 +++++++++++ Sources/Demos/ExporterPipelineDemo/README.md | 21 + Sources/Demos/ExporterPipelineDemo/main.swift | 13 + .../InteractionGameplayDemo/AppDelegate.swift | 96 ++++ .../InteractionGameplayDemo/GameScene.swift | 219 ++++++++ .../Demos/InteractionGameplayDemo/README.md | 22 + .../Demos/InteractionGameplayDemo/main.swift | 13 + .../LargeSceneStreamingDemo/AppDelegate.swift | 217 ++++++++ .../LargeSceneStreamingDemo/GameScene.swift | 245 +++++++++ .../Demos/LargeSceneStreamingDemo/README.md | 25 + .../Demos/LargeSceneStreamingDemo/main.swift | 13 + .../RenderingQualityDemo/AppDelegate.swift | 476 ++++++++++++++++++ .../RenderingQualityDemo/GameScene.swift | 254 ++++++++++ Sources/Demos/RenderingQualityDemo/README.md | 22 + Sources/Demos/RenderingQualityDemo/main.swift | 13 + .../ShowcaseDemo}/AppDelegate.swift | 2 +- .../ShowcaseDemo}/DemoHUD.swift | 0 .../ShowcaseDemo}/DemoState.swift | 0 .../ShowcaseDemo}/GameScene.swift | 0 .../ShowcaseDemo}/main.swift | 2 +- Sources/Demos/StarterDemo/AppDelegate.swift | 95 ++++ Sources/Demos/StarterDemo/GameScene.swift | 138 +++++ Sources/Demos/StarterDemo/README.md | 23 + Sources/Demos/StarterDemo/main.swift | 13 + .../Resources/Animations/idle/idle.untold | Bin 328408 -> 328408 bytes docs/Architecture/asset_remote_streaming.md | 4 +- scripts/next-version.sh | 6 +- 30 files changed, 2535 insertions(+), 16 deletions(-) create mode 100644 Sources/Demos/ExporterPipelineDemo/AppDelegate.swift create mode 100644 Sources/Demos/ExporterPipelineDemo/GameScene.swift create mode 100644 Sources/Demos/ExporterPipelineDemo/README.md create mode 100644 Sources/Demos/ExporterPipelineDemo/main.swift create mode 100644 Sources/Demos/InteractionGameplayDemo/AppDelegate.swift create mode 100644 Sources/Demos/InteractionGameplayDemo/GameScene.swift create mode 100644 Sources/Demos/InteractionGameplayDemo/README.md create mode 100644 Sources/Demos/InteractionGameplayDemo/main.swift create mode 100644 Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift create mode 100644 Sources/Demos/LargeSceneStreamingDemo/GameScene.swift create mode 100644 Sources/Demos/LargeSceneStreamingDemo/README.md create mode 100644 Sources/Demos/LargeSceneStreamingDemo/main.swift create mode 100644 Sources/Demos/RenderingQualityDemo/AppDelegate.swift create mode 100644 Sources/Demos/RenderingQualityDemo/GameScene.swift create mode 100644 Sources/Demos/RenderingQualityDemo/README.md create mode 100644 Sources/Demos/RenderingQualityDemo/main.swift rename Sources/{DemoGame => Demos/ShowcaseDemo}/AppDelegate.swift (99%) rename Sources/{DemoGame => Demos/ShowcaseDemo}/DemoHUD.swift (100%) rename Sources/{DemoGame => Demos/ShowcaseDemo}/DemoState.swift (100%) rename Sources/{DemoGame => Demos/ShowcaseDemo}/GameScene.swift (100%) rename Sources/{DemoGame => Demos/ShowcaseDemo}/main.swift (91%) create mode 100644 Sources/Demos/StarterDemo/AppDelegate.swift create mode 100644 Sources/Demos/StarterDemo/GameScene.swift create mode 100644 Sources/Demos/StarterDemo/README.md create mode 100644 Sources/Demos/StarterDemo/main.swift diff --git a/Package.swift b/Package.swift index fb8525efc..3fc1cedd8 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 0b6b6f76b..9693c0943 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 000000000..226b0b5c2 --- /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 000000000..0395bc7df --- /dev/null +++ b/Sources/Demos/ExporterPipelineDemo/GameScene.swift @@ -0,0 +1,296 @@ +// +// GameScene.swift +// ExporterPipelineDemo +// + +#if os(macOS) + import Foundation + import simd + import UntoldEngine + + enum ExportedAssetOption: String, CaseIterable, Identifiable { + case stadium = "stadium" + case redplayer = "redplayer" + case ball = "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: Sendable { + var assetName: String = "-" + var meshCount: Int = 0 + var totalVertices: Int = 0 + var totalIndices: Int = 0 + var found = false + } + + struct PipelineStatus: Sendable { + 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 000000000..3c47daa15 --- /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 000000000..e9ef74871 --- /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 000000000..9aada6bf5 --- /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 000000000..67d27814b --- /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 = 2.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 000000000..b03f9e2b7 --- /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 000000000..542ce9258 --- /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 000000000..c55ce9edb --- /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 000000000..815777d94 --- /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(true) + } + + 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 000000000..53c9f623b --- /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 000000000..0be3effa4 --- /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 000000000..ad09e472e --- /dev/null +++ b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift @@ -0,0 +1,476 @@ +// +// 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 000000000..3b61a807d --- /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 000000000..d1295dfa0 --- /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 000000000..e3181f43a --- /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 7b9499644..245695bd8 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 45574d126..549ff60fd 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 000000000..2daa6a340 --- /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 000000000..6507bf421 --- /dev/null +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -0,0 +1,138 @@ +// +// 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) + ) + } + + 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 000000000..e4a5f9903 --- /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 000000000..482d749e5 --- /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 bf4c8bf01fab5a7e3fea79f26b1105f5ee70e0fd..eacb6f1719e3f96f5f8cd049d42961a7c26b80c0 100644 GIT binary patch delta 6066 zcmZYCU5Hgx7{>9p=@cOZ5egy=4AMXdk_i(LjOP#p5kwNa6hRKJEHr8@i!uDDaAJxIrmJoSteXVsrVD9;A?T|2+53F#{X7ST(Vy==d-ghO_WpZi zX5*EajmKsu)~?UlwjE=KI|r8kdF8h)cXl4nJI|atdwS)?z1JUIx#Qotd&bYL{INMb z`^?SV#rgRqi&x2c8sD21f8E7a@-{Jd7c+ZZ%>6{u(%{5Rtjug#%zag?xq^8uvn~CZK<*RW_x6?_DlipBmAbN@-KxgYsEuU~Og}in*T@EBPa_cq)pye=Jr~ z{zTC1v{*b+#Y+BE%ue|IE(Hp!*kMC4VUv&vG&Mb7Ccb)wH_*%+5El z>c8j!v63%}nSCwh{*72Pgjnpq%YyDpVkLhk76BsW{=HbqKZu$ASliu={)>8%!IFP& z*&DsdBfZHRF7+nII?tuG&Q#a>ul4vd0-i1Yc^4Z3o(%!dhJa^7!1paxr|zG4fI~yT z>%psH2zZ{0H|erPu8^gvZX^dGR%Aj90ncxUfM-L%vmxNw5b$e>mLCDng9vyw1Uwr8 zo(%!dhJYVfwfpU0ndhjXG6e~A>J1m5b$gW_@SlYiPuNiHw64}uy66L$A*AsL%_2k z;Mo!io)H1hhJa^7z_TIX*%0t-2>7u2KW}kW{S7xRg9vy%c&iu!p63woYzX*~#roGf zeR~6IL%@#)YeT@ZA>i2%@N5WpHcvh+7$3110$vV|fM-L%vmxNw5b!YUzv@-r19ri2%@C{4V@+06ISQ!GI4FS)FfM-L%vmxNw;^Dnt5CPAF2zWLGJR1U@ z4FS)FfUj(@{`Dq2D1#**5<|fA8zSJ@5b$gW_@<@$#7A2QL%=r&>-{k+hJfce1Uwr8 zo-G1C36^|Z3<0kPpAbX9^Be-64FO+UfBow#UXVcqydHc~3<1w`2zWLGe9Q3sFA?x9 z3`4-PA>i2%@N5WpHUvBy0zNS!;MoxHYzTNZ1Uwr8o(%!tw|f2S&2@f&!y6I;UJt$~ zhJfce1Uwr8zHQa>|1u%UXGshJ-#!>0)3O)>p63woYzX+Zd#lfSbPGnn^B@AA?8vqG PvG}_2(zW@{`o8}G4X>qv delta 6066 zcmZwJUx*h~6bJCrrXqqMp&){^1Pf6}7HmaedyAq7BMA;fAS{Auksy^2LEO#SG_laA zXr-bg`*UxJie{PSDj5_#^dd+Q8PZEny(~lz(K&PX{LY!**MVj4{rt|EJ2Sue&3rFS zFTXUseBa*DQ#U_*{pkm`-0{e&jsJXleElE)J@@mmMNdy3-t`-Qd(ZlE-~L-yAN}$7 zmHEKT%$Y4uEILcF)l$~!|7bG3d)R8Z35=P+h^D}p-;S1T;fS3P&4Mvs1gqsQFgf4} zJm@80%y|VM(I}XlF&OhW7*PVN3 z(Y_w4$oB+R%K{+E z{0c^N8I1W)Fu5_oi2m+d?k@FQAH{0<7fd+GU+?Ex%Lo|rLNKDmV6`ksx_)nm%uAt= zXc<^7*MTvw03%unR?8~0Jb3spuQnW*{1m}zxdDuM42n|nVdiKnjs|F)`Hb? z8<;!;z=-YytK}{*=5>km`u88V2?}eu7mWFSFro*+f3;J^#sJl)HZu81rT@qOD-^!~|pB21c}9OxBN=zm{j9P#(8nM9+iO z@&Xw1i(vAocFXgxUgnn^2e0FuU_`Hg$#Wj8mRG@uc7tW+*8t@u0YqPM{0RP{7>D&Umt6qgYgWlXe(1lxokA)WCqgjBDW&u8$1^8$d;Dc#+KQcI) z_JJPt2`~%rp;>BXq0Rz)tg`?gtp)h()GpUW*r5e@uhgq`8R{&+$2tq}(Ja7s*4EcE zo`W4)fbTa`^0|)m`y5F+@=f=*F!@|ZdeDo(vL<-_*f(unntZM!ZC(au0p4r1cLmg0 zfRA~o)7TREWk&z03Xc)e0Tos13lvn$%}NPH&+Yr?s~?Ye6Azy zZ2{h0ds~2yW&wWQbbW1}OkSiTZQhi04BD_s@1f3-JBs z9Z+WhKGs=)k7fZrnjCoBpPql)^axvkH)&U4XY#p@^o(18ch}zOI_CU3+&Y zpLg$HZEgYHKhWkD;G!03Xc)d^8L2 z(Ja7w{^%BN0X`O5fRAPYKAHviXcpk3S%4o{?iN0n2G9-kj9Y+r*RSai)LDR!br#^G zS%8nG0X{qENEdDa-dz`N0X~`q_-Gd3qgjBDW&vKz*4KqwfRBY1;GdCiz@PdSkW#@2%!K-WK59wfB7TxsJ3~0{rbg)#eMSN8Q3L Vz`IZFx-v5&f7hP8GP7WA>;FsBtQG(O diff --git a/docs/Architecture/asset_remote_streaming.md b/docs/Architecture/asset_remote_streaming.md index 2022ba964..aee77ebc9 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/scripts/next-version.sh b/scripts/next-version.sh index b8a3f4462..c1705cb42 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}"'")/' \ From d9dbcd5165eb818b22b721f60a111b3605f1061a Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 25 Jun 2026 23:19:42 -0700 Subject: [PATCH 3/4] [Demo] Fixed demo --- Sources/Demos/InteractionGameplayDemo/GameScene.swift | 2 +- Sources/Demos/LargeSceneStreamingDemo/GameScene.swift | 2 +- Sources/Demos/StarterDemo/GameScene.swift | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Demos/InteractionGameplayDemo/GameScene.swift b/Sources/Demos/InteractionGameplayDemo/GameScene.swift index 67d27814b..acb599702 100644 --- a/Sources/Demos/InteractionGameplayDemo/GameScene.swift +++ b/Sources/Demos/InteractionGameplayDemo/GameScene.swift @@ -14,7 +14,7 @@ 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 = 2.0 + static let maxPlayerSpeed: Float = 5.0 static let turnSpeed: Float = 5.0 static let ballRollDegreesPerSecond: Float = 240.0 } diff --git a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift index 815777d94..454a5c620 100644 --- a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift +++ b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift @@ -50,7 +50,7 @@ configureEngine() createCamera() createLight() - setSceneReady(true) + setSceneReady(false) } func loadPreset(_ preset: RemoteScenePreset) { diff --git a/Sources/Demos/StarterDemo/GameScene.swift b/Sources/Demos/StarterDemo/GameScene.swift index 6507bf421..85ca70fe0 100644 --- a/Sources/Demos/StarterDemo/GameScene.swift +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -36,6 +36,8 @@ angle: 25.0 * deltaTime, axis: simd_float3(0.0, 1.0, 0.0) ) + + setSceneReady(true) } func handleInput() { From e50d61c03dac3658e081196e988dc05d4cf16858 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 25 Jun 2026 23:20:23 -0700 Subject: [PATCH 4/4] [Chores] Formatted files --- .../ExporterPipelineDemo/GameScene.swift | 23 ++++++++++++------- .../RenderingQualityDemo/AppDelegate.swift | 16 +++++++++---- Sources/Demos/StarterDemo/GameScene.swift | 2 +- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Sources/Demos/ExporterPipelineDemo/GameScene.swift b/Sources/Demos/ExporterPipelineDemo/GameScene.swift index 0395bc7df..5bdcafa1b 100644 --- a/Sources/Demos/ExporterPipelineDemo/GameScene.swift +++ b/Sources/Demos/ExporterPipelineDemo/GameScene.swift @@ -9,11 +9,13 @@ import UntoldEngine enum ExportedAssetOption: String, CaseIterable, Identifiable { - case stadium = "stadium" - case redplayer = "redplayer" - case ball = "ball" + case stadium + case redplayer + case ball - var id: String { rawValue } + var id: String { + rawValue + } var title: String { switch self { @@ -39,11 +41,16 @@ case idle case running - var id: String { rawValue } - var title: String { rawValue.capitalized } + var id: String { + rawValue + } + + var title: String { + rawValue.capitalized + } } - struct ValidationSummary: Sendable { + struct ValidationSummary { var assetName: String = "-" var meshCount: Int = 0 var totalVertices: Int = 0 @@ -51,7 +58,7 @@ var found = false } - struct PipelineStatus: Sendable { + struct PipelineStatus { var loadedEntity = "None" var assetPath = "-" var assetExists = false diff --git a/Sources/Demos/RenderingQualityDemo/AppDelegate.swift b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift index ad09e472e..99cbd8222 100644 --- a/Sources/Demos/RenderingQualityDemo/AppDelegate.swift +++ b/Sources/Demos/RenderingQualityDemo/AppDelegate.swift @@ -54,7 +54,9 @@ case cinematic = "Cinematic" case inspection = "Inspection" - var id: String { rawValue } + var id: String { + rawValue + } } enum AntiAliasingOption: String, CaseIterable, Identifiable { @@ -63,7 +65,9 @@ case smaa = "SMAA" case msaa = "MSAA" - var id: String { rawValue } + var id: String { + rawValue + } var mode: AntiAliasingMode { switch self { @@ -80,7 +84,9 @@ case balanced = "Balanced" case high = "High" - var id: String { rawValue } + var id: String { + rawValue + } var quality: SSAOQuality { switch self { @@ -102,7 +108,9 @@ case smaaBlend = "SMAA Blend" case smaaDifference = "SMAA Diff" - var id: String { rawValue } + var id: String { + rawValue + } var mode: RenderDebugViewMode { switch self { diff --git a/Sources/Demos/StarterDemo/GameScene.swift b/Sources/Demos/StarterDemo/GameScene.swift index 85ca70fe0..d53f8ba67 100644 --- a/Sources/Demos/StarterDemo/GameScene.swift +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -36,7 +36,7 @@ angle: 25.0 * deltaTime, axis: simd_float3(0.0, 1.0, 0.0) ) - + setSceneReady(true) }