From 5bc71f3b3623da013f092f1ffc84b601563f2636 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 12 Aug 2026 22:44:51 -0700 Subject: [PATCH] [Docs] Add tutorials for demos --- Sources/Demos/StarterDemo/GameScene.swift | 2 - docs/Tutorials/ExporterPipelineDemo.md | 221 ++++++++++++++++ docs/Tutorials/InteractionGameplayDemo.md | 248 ++++++++++++++++++ docs/Tutorials/LargeSceneStreamingDemo.md | 226 +++++++++++++++++ docs/Tutorials/LightingDemo.md | 289 +++++++++++++++++++++ docs/Tutorials/RenderingQualityDemo.md | 215 ++++++++++++++++ docs/Tutorials/ShowcaseDemo.md | 258 +++++++++++++++++++ docs/Tutorials/StarterDemo.md | 291 ++++++++++++++++++++++ docs/Tutorials/index.md | 49 ++++ mkdocs.yml | 9 + 10 files changed, 1806 insertions(+), 2 deletions(-) create mode 100644 docs/Tutorials/ExporterPipelineDemo.md create mode 100644 docs/Tutorials/InteractionGameplayDemo.md create mode 100644 docs/Tutorials/LargeSceneStreamingDemo.md create mode 100644 docs/Tutorials/LightingDemo.md create mode 100644 docs/Tutorials/RenderingQualityDemo.md create mode 100644 docs/Tutorials/ShowcaseDemo.md create mode 100644 docs/Tutorials/StarterDemo.md create mode 100644 docs/Tutorials/index.md diff --git a/Sources/Demos/StarterDemo/GameScene.swift b/Sources/Demos/StarterDemo/GameScene.swift index 4904ace31..1546453d0 100644 --- a/Sources/Demos/StarterDemo/GameScene.swift +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -37,8 +37,6 @@ angle: 25.0 * deltaTime, axis: simd_float3(0.0, 1.0, 0.0) ) - - setSceneReady(true) } func handleInput() { diff --git a/docs/Tutorials/ExporterPipelineDemo.md b/docs/Tutorials/ExporterPipelineDemo.md new file mode 100644 index 000000000..1bc489d89 --- /dev/null +++ b/docs/Tutorials/ExporterPipelineDemo.md @@ -0,0 +1,221 @@ +# Exporter Pipeline Demo + +The Exporter Pipeline Demo shows how exported `.untold` assets are loaded, +validated, and animated at runtime. It focuses on the handoff between the asset +pipeline and the engine API. + +Run it from the repository root: + +```bash +swift run ExporterPipelineDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/ExporterPipelineDemo/AppDelegate.swift` | Presents asset, animation, reset, and validation UI. | +| `Sources/Demos/ExporterPipelineDemo/GameScene.swift` | Loads exported assets, applies transforms, loads animation clips, and reports pipeline status. | +| `Sources/Demos/DemoUtils/DemoUtils.swift` | Provides shared resource, camera, light, and input helpers. | + +## What This Demo Teaches + +The main runtime asset API is: + +```swift +setEntityMeshAsync(entityId: entity, filename: "redplayer", withExtension: "untold") { success in + setSceneReady(success) +} +``` + +Use it for always-resident assets such as characters, props, vehicles, or small +environment pieces. The completion handler runs after the mesh has been parsed +and registered with the engine. + +## Asset Options + +The demo presents three exported assets: + +```swift +enum ExportedAssetOption: String, CaseIterable, Identifiable { + case stadium + case redplayer + case ball +} +``` + +The enum keeps UI labels, default scale, and animation support close to the +asset selection: + +```swift +var supportsAnimation: Bool { + self == .redplayer +} + +var defaultScale: simd_float3 { + switch self { + case .ball: simd_float3(repeating: 0.8) + default: simd_float3(repeating: 1.0) + } +} +``` + +This is demo-side organization. The engine only needs an entity ID, a filename, +an extension, and a completion handler. + +## Loading An Exported Asset + +Before loading a new asset, the demo marks the scene not ready and destroys the +previous entity: + +```swift +setSceneReady(false) + +if let loadedEntity { + destroyEntity(entityId: loadedEntity) + self.loadedEntity = nil +} +``` + +Then it creates a new entity and loads the selected `.untold` file: + +```swift +let entity = createEntity() +setEntityName(entityId: entity, name: option.title) +setEntityMeshAsync(entityId: entity, filename: option.rawValue, withExtension: "untold") { success in + if success { + loadedEntity = entity + translateTo(entityId: entity, position: .zero) + scaleTo(entityId: entity, scale: option.defaultScale) + } + + setSceneReady(success) +} +``` + +For assets that need import-orientation correction, apply transforms after the +mesh load succeeds: + +```swift +if option == .stadium { + rotateTo(entityId: entity, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) +} +``` + +## Runtime Animation Loading + +The demo has two animation clips: + +```swift +enum ExportedAnimationOption: String, CaseIterable, Identifiable { + case idle + case running +} +``` + +Animation is loaded onto the already-loaded entity: + +```swift +setEntityAnimations( + entityId: loadedEntity, + filename: option.rawValue, + withExtension: "untold", + name: option.rawValue +) +changeAnimation(entityId: loadedEntity, name: option.rawValue) +``` + +`setEntityAnimations(...)` registers the clip under a name. `changeAnimation(...)` +starts playback of that named clip. + +The demo only applies these clips when the selected asset supports them: + +```swift +guard let loadedEntity, loadedAsset?.supportsAnimation == true else { + status.message = "Selected asset does not support the demo animations." + return +} +``` + +In your own project, use the same kind of guard when a UI can select assets with +different capabilities. + +## Querying Loaded Clips + +After loading an animation, the demo queries the entity for available clips: + +```swift +let clips = getAllAnimationClips(entityId: loadedEntity).sorted() +status.animationClips = clips.isEmpty ? "None" : clips.joined(separator: ", ") +``` + +This is useful for debugging imported animation assets. It also gives tooling a +simple way to show what clips are available on an entity. + +## Validation Metadata + +When exported with validation enabled, assets can have a sidecar +`.validation.json` file. The demo reads that file with standard Swift +file APIs: + +```swift +let data = try? Data(contentsOf: url) +let decoded = try? JSONDecoder().decode(ValidationFile.self, from: data) +``` + +This is not an engine runtime requirement. It is a pipeline diagnostic that helps +confirm what the exporter produced: + +- asset name +- mesh count +- total vertices +- total indices + +## API Pattern To Remember + +For a normal exported model: + +```swift +setSceneReady(false) + +let entity = createEntity() +setEntityName(entityId: entity, name: "Red Player") +setEntityMeshAsync(entityId: entity, filename: "redplayer", withExtension: "untold") { success in + guard success else { + setSceneReady(false) + return + } + + translateTo(entityId: entity, position: .zero) + setSceneReady(true) +} +``` + +For an animated exported model: + +```swift +setEntityAnimations(entityId: entity, filename: "idle", withExtension: "untold", name: "idle") +setEntityAnimations(entityId: entity, filename: "running", withExtension: "untold", name: "running") +changeAnimation(entityId: entity, name: "idle") +``` + +## What To Change First + +Try these changes in `Sources/Demos/ExporterPipelineDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Load a different default asset | Change the `loadAsset(...)` call in `init()`. | +| Add another asset option | Add a case to `ExportedAssetOption` and provide a matching `.untold` file. | +| Change default placement | Edit `translateTo(...)`, `scaleTo(...)`, or `rotateTo(...)` after load success. | +| Add an animation clip | Add a case to `ExportedAnimationOption`, call `setEntityAnimations(...)`, then `changeAnimation(...)`. | +| Show more validation data | Extend `ValidationSummary` and decode more fields from the validation JSON. | + +## Related Documentation + +- [Exporter](../API/UsingTheExporter.md) +- [Animation System](../API/UsingAnimationSystem.md) +- [Registration System](../API/UsingRegistrationSystem.md) +- [Async Loading](../API/UsingAsyncLoading.md) +- [Optimizations](../API/Optimizations.md) + diff --git a/docs/Tutorials/InteractionGameplayDemo.md b/docs/Tutorials/InteractionGameplayDemo.md new file mode 100644 index 000000000..f467df1f3 --- /dev/null +++ b/docs/Tutorials/InteractionGameplayDemo.md @@ -0,0 +1,248 @@ +# Interaction / Gameplay Demo + +The Interaction / Gameplay Demo shows how to combine asset loading, input, +animation switching, physics-backed steering, and scene graph parenting into a +small gameplay-style loop. + +Run it from the repository root: + +```bash +swift run InteractionGameplayDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/InteractionGameplayDemo/AppDelegate.swift` | Creates the renderer, `SceneView`, and callbacks. | +| `Sources/Demos/InteractionGameplayDemo/GameScene.swift` | Loads the stadium, player, and ball; handles input; switches animation; drives movement. | +| `Sources/Demos/DemoUtils/DemoUtils.swift` | Provides shared setup helpers for resources, camera, light, and input. | + +## What This Demo Teaches + +This demo is about turning engine systems into a simple gameplay loop: + +1. Load a static scene. +2. Load a player entity. +3. Add animations and physics behavior to the player. +4. Load a ball and parent it to the player. +5. Read keyboard input. +6. Switch animation and movement behavior every frame. + +The core APIs are: + +```swift +setEntityMeshAsync(...) +setEntityAnimations(...) +changeAnimation(...) +setEntityKinetics(...) +pausePhysicsComponent(...) +steerSeek(...) +setParent(...) +``` + +## Scene Loading + +The demo loads three assets: + +```swift +loadStadium(...) +loadPlayer(...) +loadBall(...) +``` + +Each loader follows the same async mesh pattern: + +```swift +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) + completion(entity, true) +} +``` + +The player load is the most important because it also configures gameplay +systems after the mesh succeeds. + +## Animation Setup + +The player registers two animation clips: + +```swift +setEntityAnimations(entityId: entity, filename: "running", withExtension: "untold", name: "running") +setEntityAnimations(entityId: entity, filename: "idle", withExtension: "untold", name: "idle") +changeAnimation(entityId: entity, name: "idle") +``` + +The clip names are the runtime handles used later: + +```swift +private var currentAnimation = "idle" + +private func playAnimationIfNeeded(_ name: String) { + guard let redPlayer, currentAnimation != name else { return } + currentAnimation = name + changeAnimation(entityId: redPlayer, name: name) +} +``` + +That guard avoids repeatedly restarting the same animation every frame. + +## Physics And Steering Setup + +The player is configured for physics-backed movement: + +```swift +setEntityKinetics(entityId: entity) +setGravityScale(entityId: entity, gravityScale: 0.0) +setLinearDragCoefficient(entityId: entity, coefficients: simd_float2(1.5, 0.2)) +pausePhysicsComponent(entityId: entity, isPaused: true) +``` + +`setEntityKinetics(...)` prepares the entity for physics movement. Gravity is +disabled for this top-down demo, drag is tuned for stable movement, and physics +starts paused until the user presses a movement key. + +The actual movement happens in `update(deltaTime:)`: + +```swift +steerSeek( + entityId: redPlayer, + targetPosition: targetPosition, + maxSpeed: Constants.maxPlayerSpeed, + deltaTime: deltaTime, + turnSpeed: Constants.turnSpeed +) +``` + +`steerSeek(...)` calculates steering behavior and applies movement through the +physics system. The demo computes the target from keyboard input. + +## Input To Gameplay State + +Input handling is deliberately small: + +```swift +let input = InputSystem.shared.keyState +startMoving = input.wPressed || input.aPressed || input.sPressed || input.dPressed +``` + +The update loop uses `startMoving` to decide which systems should run: + +```swift +if startMoving { + playAnimationIfNeeded("running") + pausePhysicsComponent(entityId: redPlayer, isPaused: false) +} else { + playAnimationIfNeeded("idle") + pausePhysicsComponent(entityId: redPlayer, isPaused: true) + return +} +``` + +This keeps input polling separate from simulation. `handleInput()` records +intent. `update(deltaTime:)` applies the consequences. + +## Building A Movement Target + +The movement target is derived from the player's current position: + +```swift +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 +} +``` + +The steering system then moves the player toward that short-range target. This +is a simple pattern for keyboard-directed character motion. + +## Parenting The Ball + +The ball is attached to the player once both async loads have completed: + +```swift +private func attachBallToPlayerIfReady() { + guard ballAttached == false, let ball, let redPlayer else { return } + setParent(childId: ball, parentId: redPlayer) + ballAttached = true +} +``` + +`setParent(childId:parentId:)` puts the ball under the player's transform. The +ball keeps its local offset: + +```swift +translateTo(entityId: entity, position: Constants.ballLocalOffset) +``` + +After parenting, that offset is interpreted relative to the player. + +## Per-Frame Ball Rotation + +The demo rotates the ball while the player moves: + +```swift +rotateBy( + entityId: ball, + angle: Constants.ballRollDegreesPerSecond * deltaTime, + axis: getRightAxisVector(entityId: ball) +) +``` + +This combines transform queries and transform updates. `getRightAxisVector(...)` +returns the ball's current right axis, and `rotateBy(...)` applies incremental +rotation around that axis. + +## API Pattern To Remember + +Gameplay code usually becomes a small state machine around engine APIs: + +```swift +handleInput() // read input and update intent +update(deltaTime:) // switch animation, physics, steering, transforms +``` + +Use `isSceneReady()` before reading or mutating loaded entities: + +```swift +if gameMode == false { return } +if isSceneReady() == false { return } +``` + +That keeps gameplay systems from running before async content is available. + +## What To Change First + +Try these changes in `Sources/Demos/InteractionGameplayDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Make the player faster | Increase `Constants.maxPlayerSpeed`. | +| Make turning slower | Decrease `Constants.turnSpeed`. | +| Change input mapping | Edit `movementTarget(from:)`. | +| Add another animation state | Register another clip with `setEntityAnimations(...)` and switch with `changeAnimation(...)`. | +| Detach the ball | Remove or conditionalize `setParent(childId:parentId:)`. | +| Change ball placement | Edit `Constants.ballLocalOffset`. | + +## Related Documentation + +- [Animation System](../API/UsingAnimationSystem.md) +- [Physics System](../API/UsingPhysicsSystem.md) +- [Steering System](../API/UsingSteeringSystem.md) +- [Scene Graph](../API/UsingScenegraph.md) +- [Input System](../API/UsingInputSystem.md) + diff --git a/docs/Tutorials/LargeSceneStreamingDemo.md b/docs/Tutorials/LargeSceneStreamingDemo.md new file mode 100644 index 000000000..3554fc524 --- /dev/null +++ b/docs/Tutorials/LargeSceneStreamingDemo.md @@ -0,0 +1,226 @@ +# Large Scene Streaming Demo + +The Large Scene Streaming Demo shows the manifest-driven tiled scene workflow. +It loads remote scene manifests, lets the geometry streaming system bring tiles +in and out as the camera moves, and exposes spatial debug overlays for streamed +content. + +Run it from the repository root: + +```bash +swift run LargeSceneStreamingDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/LargeSceneStreamingDemo/AppDelegate.swift` | Presents scene selection, manifest loading, and debug overlay controls. | +| `Sources/Demos/LargeSceneStreamingDemo/GameScene.swift` | Configures streaming, loads remote manifests, builds a fallback field, and handles camera input. | +| `Sources/Demos/DemoUtils/DemoUtils.swift` | Provides shared setup helpers for camera, light, renderer settings, and input. | + +## What This Demo Teaches + +For normal models, use: + +```swift +setEntityMeshAsync(...) +``` + +For large worlds, use: + +```swift +setEntityStreamScene(entityId: root, url: manifestURL) { success in + setSceneReady(success) +} +``` + +`setEntityStreamScene(...)` registers a manifest-backed scene under an entity +root. The engine then streams tile geometry as the camera moves. + +## Streaming Configuration + +The demo enables geometry streaming and tunes concurrency: + +```swift +setGeometryStreaming(.enabled(true)) +setGeometryStreaming(.tileConcurrency(2)) +setGeometryStreaming(.meshConcurrency(3)) +setGeometryStreaming(.lodConcurrency(4)) +setGeometryStreaming(.hlodConcurrency(4)) +``` + +It also configures candidate selection: + +```swift +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)) +``` + +These calls do not load content by themselves. They configure how the streaming +system behaves once a tiled scene is registered. + +## Loading A Remote Manifest + +The demo defines remote presets: + +```swift +enum RemoteScenePreset: String, CaseIterable { + case dungeon = "Dungeon" + case city = "City" +} +``` + +Loading a preset creates a root entity, names it, and registers the manifest: + +```swift +let root = createEntity() +setEntityName(entityId: root, name: "\(label) Stream Root") +streamedSceneRoot = root + +setEntityStreamScene(entityId: root, url: url) { success in + if success { + setSceneReady(true) + } else { + setSceneReady(true) + } +} +``` + +The root entity gives the streamed scene a stable handle. When switching scenes, +the demo can destroy that root and clear the previous session. + +## Scene Transitions + +Before loading new content, the demo clears the previous streaming state: + +```swift +GeometryStreamingSystem.shared.forceUnloadAllParsedTiles() + +if let streamedSceneRoot { + destroyEntity(entityId: streamedSceneRoot) + self.streamedSceneRoot = nil +} + +clearSceneBatches() +``` + +`forceUnloadAllParsedTiles()` is important when switching tiled scenes. It frees +resident tile memory immediately instead of waiting for normal distance-based +unload behavior. + +## Camera Movement Drives Streaming + +The demo uses the free-fly camera pattern: + +```swift +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: 9.0, + deltaTime: 1.0 / 60.0 +) +``` + +As the camera moves, the geometry streaming system queries nearby tiles, +prioritizes candidates, loads new geometry, and unloads tiles that leave range. + +## Spatial Debug Overlays + +The demo exposes three useful debug overlays: + +```swift +func setTileBoundsDebug(_ enabled: Bool) { + setSpatialDebug(.tileBounds(enabled: enabled)) +} + +func setLodDebug(_ enabled: Bool) { + setSpatialDebug(.lodLevels(enabled)) +} + +func setTextureTierDebug(_ enabled: Bool) { + setSpatialDebug(.textureStreamingTiers(enabled)) +} +``` + +Use tile bounds to see manifest structure. Use LOD levels to inspect which +representation is active. Use texture tiers to inspect texture streaming state. + +## Offline Fallback Field + +The demo also includes a procedural fallback field for offline testing: + +```swift +let entity = createEntity() +setEntityName(entityId: entity, name: "Fallback_\(x)_\(z)") +setEntityMeshDirect(entityId: entity, meshes: cubeMesh, assetName: "fallback_cube") +translateTo(entityId: entity, position: position) +setEntityStaticBatchComponent(entityId: entity) +``` + +After creating the field, it enables static batching: + +```swift +setBatching(.enabled(true)) +generateBatches() +``` + +This fallback does not demonstrate true tile streaming. It demonstrates how a +large always-resident static field can be marked for batching. + +## API Pattern To Remember + +For a remote streamed scene: + +```swift +setGeometryStreaming(.enabled(true)) + +let root = createEntity() +setEntityName(entityId: root, name: "City") + +setEntityStreamScene(entityId: root, url: manifestURL) { success in + setSceneReady(success) +} +``` + +For an always-resident fallback or small static scene: + +```swift +setEntityStaticBatchComponent(entityId: entity) +setBatching(.enabled(true)) +generateBatches() +``` + +Do not call `generateBatches()` for normal streamed tile loads. The streaming +path updates batching incrementally as tile residency changes. + +## What To Change First + +Try these changes in `Sources/Demos/LargeSceneStreamingDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Load a different manifest | Add a new `RemoteScenePreset` URL. | +| Increase streaming reach | Increase `setGeometryStreaming(.queryRadius(...))`. | +| Load fewer tiles at once | Lower `.tileConcurrency(...)` or `.meshConcurrency(...)`. | +| Disable tile bounds by default | Change `setSpatialDebug(.tileBounds(enabled: true))`. | +| Change camera speed | Edit `Constants.cameraMoveSpeed`. | +| Make the fallback field larger | Increase `Constants.fallbackGridSize`. | + +## Related Documentation + +- [Geometry Streaming](../API/UsingGeometryStreamingSystem.md) +- [LOD System](../API/UsingLODSystem.md) +- [Static Batching](../API/UsingStaticBatchingSystem.md) +- [Spatial Debugger](../API/SpatialDebugger.md) +- [Tile-Based Streaming](../Architecture/tilebasedstreaming.md) + diff --git a/docs/Tutorials/LightingDemo.md b/docs/Tutorials/LightingDemo.md new file mode 100644 index 000000000..6d66f36ed --- /dev/null +++ b/docs/Tutorials/LightingDemo.md @@ -0,0 +1,289 @@ +# Lighting Demo + +The Lighting Demo shows how Untold Engine represents lights in the ECS. Each +light starts as a normal entity, then receives a light component with one of the +light creation APIs: + +- `createDirLight(entityId:)` +- `createPointLight(entityId:)` +- `createSpotLight(entityId:)` +- `createAreaLight(entityId:)` + +After that, shared and type-specific light properties are updated with +`setLight(entityId:, _:)`. + +Run it from the repository root: + +```bash +swift run LightingDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/LightingDemo/AppDelegate.swift` | Creates the renderer, presents `SceneView`, and exposes UI controls for light values. | +| `Sources/Demos/LightingDemo/GameScene.swift` | Builds the test scene, creates each light type, and applies runtime light changes. | +| `Sources/Demos/DemoUtils/DemoUtils.swift` | Provides shared setup helpers for renderer settings, input, and camera creation. | + +## What This Demo Teaches + +The Starter Demo introduced the application shape: renderer, callbacks, scene +view, entities, transforms, and input. The Lighting Demo keeps that same shape +and focuses on one engine idea: lighting is entity-driven. + +The same entity workflow applies to cameras, renderable objects, and lights: + +```swift +let light = createEntity() +setEntityName(entityId: light, name: "Point Light") +createPointLight(entityId: light) +translateTo(entityId: light, position: simd_float3(3.5, 3.5, 2.0)) +setLight(entityId: light, .color(simd_float3(1.0, 0.55, 0.1))) +setLight(entityId: light, .intensity(3.0)) +setLight(entityId: light, .point(.radius(9.0))) +``` + +The entity owns transform and identity. The light component owns illumination +behavior. `setLight(...)` is the main API for changing how the light affects the +renderer. + +## Scene Setup + +Before creating lights, the demo builds a small scene with a floor, a back wall, +and three spheres. These objects use the same renderable entity pattern from the +Starter Demo: + +```swift +let floor = createEntity() +setEntityName(entityId: floor, name: "Floor") +setEntityMeshDirect( + entityId: floor, + meshes: BasicPrimitives.createPlane(width: 14.0, depth: 12.0), + assetName: "floor" +) +updateMaterialColor(entityId: floor, color: Color(red: 0.55, green: 0.55, blue: 0.57)) +``` + +The wall adds scale and translation: + +```swift +let wall = createEntity() +setEntityName(entityId: wall, name: "Back Wall") +setEntityMeshDirect( + entityId: wall, + meshes: BasicPrimitives.createCube(extent: 1.0), + assetName: "wall" +) +scaleTo(entityId: wall, scale: simd_float3(14, 8, 0.2)) +translateTo(entityId: wall, position: simd_float3(0, 4, -6)) +updateMaterialColor(entityId: wall, color: Color(red: 0.70, green: 0.70, blue: 0.72)) +``` + +This scene exists to make light behavior visible. The floor catches light from +above and the wall makes directional, spot, point, and area lights easier to +compare. + +## Directional Light + +Directional lights are used for sunlight or distant key lights. They do not +need a meaningful position. Their rotation defines the light direction. + +```swift +let dir = createEntity() +setEntityName(entityId: dir, name: "Directional Light") +createDirLight(entityId: dir) +rotateTo(entityId: dir, angle: -50.0, axis: simd_float3(1, 0, 0)) +setLight(entityId: dir, .color(simd_float3(1.0, 0.95, 0.85))) +setLight(entityId: dir, .intensity(1.2)) +setLight(entityId: dir, .directional(.active)) +``` + +The important call is: + +```swift +setLight(entityId: dir, .directional(.active)) +``` + +That marks this entity as the active directional light. Only one directional +light is active for directional shading and shadows at a time. + +## Point Light + +Point lights radiate from a position in all directions. The transform position +matters because the light is local to the scene. + +```swift +let pt = createEntity() +setEntityName(entityId: pt, name: "Point Light") +createPointLight(entityId: pt) +translateTo(entityId: pt, position: simd_float3(3.5, 3.5, 2.0)) +setLight(entityId: pt, .color(simd_float3(1.0, 0.55, 0.1))) +setLight(entityId: pt, .intensity(3.0)) +setLight(entityId: pt, .point(.radius(9.0))) +``` + +The shared settings are: + +- `.color(...)` +- `.intensity(...)` + +The point-specific setting is: + +```swift +setLight(entityId: pt, .point(.radius(9.0))) +``` + +Use point lights for lamps, bulbs, glowing props, and small local light sources. + +## Spot Light + +Spot lights have both position and direction. The demo places the light above +the scene, rotates it downward, then configures the cone: + +```swift +let sp = createEntity() +setEntityName(entityId: sp, name: "Spot Light") +createSpotLight(entityId: sp) +translateTo(entityId: sp, position: simd_float3(-3.5, 6.0, 1.5)) +rotateTo(entityId: sp, angle: -65.0, axis: simd_float3(1, 0, 0)) +setLight(entityId: sp, .color(simd_float3(0.3, 0.65, 1.0))) +setLight(entityId: sp, .intensity(4.0)) +setLight(entityId: sp, .spot(.coneAngle(20.0))) +setLight(entityId: sp, .spot(.falloff(0.8))) +``` + +The spot-specific APIs are: + +```swift +setLight(entityId: sp, .spot(.coneAngle(20.0))) +setLight(entityId: sp, .spot(.falloff(0.8))) +``` + +Use spot lights for flashlights, stage lights, focused ceiling lights, and other +cone-shaped sources. + +## Area Light + +Area lights behave like rectangular emitters. Position, rotation, and scale all +matter. In this demo, the area light acts like an overhead panel: + +```swift +let ar = createEntity() +setEntityName(entityId: ar, name: "Area Light") +createAreaLight(entityId: ar) +translateTo(entityId: ar, position: simd_float3(0.0, 5.5, -1.5)) +rotateTo(entityId: ar, angle: -90.0, axis: simd_float3(1, 0, 0)) +scaleTo(entityId: ar, scale: simd_float3(5, 5, 1)) +setLight(entityId: ar, .color(simd_float3(0.75, 0.5, 1.0))) +setLight(entityId: ar, .intensity(2.0)) +setLight(entityId: ar, .area(.twoSided(false))) +``` + +The scale controls the rectangle size. The type-specific setting controls +whether light emits from both faces: + +```swift +setLight(entityId: ar, .area(.twoSided(false))) +``` + +Use area lights for windows, large panels, soft boxes, and broad architectural +lighting. + +## Runtime Light Controls + +The demo stores each light's `EntityID`: + +```swift +private var dirLight: EntityID? +private var pointLight: EntityID? +private var spotLight: EntityID? +private var areaLight: EntityID? +``` + +That lets the SwiftUI controls change live engine state later: + +```swift +func setPointLight(enabled: Bool, intensity: Float) { + guard let pointLight else { return } + setLight(entityId: pointLight, .intensity(enabled ? intensity : 0)) +} +``` + +The enabled toggles do not destroy lights. They set intensity to `0`. This is a +simple pattern when you want runtime light controls without changing the scene +structure. + +The spot light also updates its cone angle while enabled: + +```swift +func setSpotLight(enabled: Bool, intensity: Float, coneAngle: Float) { + guard let spotLight else { return } + setLight(entityId: spotLight, .intensity(enabled ? intensity : 0)) + if enabled { + setLight(entityId: spotLight, .spot(.coneAngle(coneAngle))) + } +} +``` + +This is the same API used during setup. There is no separate "editor" path for +changing lights at runtime. + +## Camera Input + +The demo keeps the same right-drag orbit camera pattern introduced in the +Starter Demo: + +```swift +guard let camera = CameraSystem.shared.activeCamera else { return } +let input = InputSystem.shared + +if input.keyState.rightMousePressed { + setOrbitOffset(entityId: camera, uTargetOffset: 10.0) + orbitCameraAround( + entityId: camera, + uDelta: simd_float2(input.mouseDeltaX, input.mouseDeltaY) + ) +} +``` + +This is useful for lighting work because camera movement is independent from the +light setup. You can inspect the same light configuration from different angles +without changing the scene. + +## API Pattern To Remember + +Most lighting code follows this sequence: + +```swift +let light = createEntity() +setEntityName(entityId: light, name: "Light Name") +createPointLight(entityId: light) +translateTo(entityId: light, position: simd_float3(0, 2, 0)) +setLight(entityId: light, .color(simd_float3(1, 0.8, 0.6))) +setLight(entityId: light, .intensity(2.0)) +setLight(entityId: light, .point(.radius(4.0))) +``` + +Change the creation call and type-specific `setLight` cases for the light type +you need. + +## What To Change First + +Try these changes in `Sources/Demos/LightingDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Make the scene warmer | Change `.color(...)` on the directional or point light. | +| Turn off a light by default | Set its `.intensity(0)` after creation. | +| Move the point light | Change `translateTo(...)` on the point light. | +| Make the spot wider | Increase `.spot(.coneAngle(...))`. | +| Make the area light larger | Change `scaleTo(...)` on the area light. | +| Emit from both sides of the area light | Change `.area(.twoSided(false))` to `.area(.twoSided(true))`. | + +## Related Documentation + +- [Lighting System](../API/UsingLightingSystem.md) +- [Transform System](../API/UsingTransformSystem.md) +- [Camera System](../API/UsingCameraSystem.md) +- [Rendering System](../API/UsingRenderingSystem.md) diff --git a/docs/Tutorials/RenderingQualityDemo.md b/docs/Tutorials/RenderingQualityDemo.md new file mode 100644 index 000000000..863551fff --- /dev/null +++ b/docs/Tutorials/RenderingQualityDemo.md @@ -0,0 +1,215 @@ +# Rendering Quality Demo + +The Rendering Quality Demo shows how to change renderer output at runtime. It +loads a small exported scene, then exposes controls for anti-aliasing, render +debug views, PostFX presets, color grading, SSAO, bloom, vignette, depth of +field, and chromatic aberration. + +Run it from the repository root: + +```bash +swift run RenderingQualityDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/RenderingQualityDemo/AppDelegate.swift` | Builds the SwiftUI controls for quality settings. | +| `Sources/Demos/RenderingQualityDemo/GameScene.swift` | Loads the scene and applies rendering/PostFX API changes. | +| `Sources/Demos/DemoUtils/DemoUtils.swift` | Provides shared resource, camera, light, and input helpers. | + +## What This Demo Teaches + +The main API lesson is that rendering quality is controlled through two facade +functions: + +```swift +setRendering(...) +setPostFX(...) +``` + +Use `setRendering(...)` for renderer-level modes such as anti-aliasing, +environment lighting, and debug views. Use `setPostFX(...)` for image-space +effects such as color grading, SSAO, bloom, vignette, and depth of field. + +## Engine Configuration + +The demo points the engine at the test asset directory and enables the runtime +features it needs: + +```swift +gameMode = true +setSceneReady(false) +setEngine(.assetBasePath(demoResourcesURL())) +setRendering(.environment(.ibl(true))) +setRendering(.environment(.visible(false))) +InputSystem.shared.registerMouseEvents() +``` + +`setEngine(.assetBasePath(...))` tells the engine where to resolve the `.untold` +models used by the demo. `setSceneReady(false)` prevents input-dependent logic +from running while the assets are still loading. + +## Loading The Scene + +The demo loads three always-resident `.untold` assets: + +```swift +let entity = createEntity() +setEntityName(entityId: entity, name: name) +setEntityMeshAsync(entityId: entity, filename: name, withExtension: "untold") { success in + completion(success ? entity : nil, success) +} +``` + +After each mesh loads, the demo applies transforms: + +```swift +rotateTo(entityId: stadium, angle: -90.0, axis: simd_float3(1.0, 0.0, 0.0)) +translateTo(entityId: player, position: simd_float3(-1.1, 0.0, 0.4)) +scaleTo(entityId: ball, scale: simd_float3(repeating: 0.75)) +``` + +This keeps rendering-quality controls separate from asset loading. Once the +scene is ready, the same `setRendering(...)` and `setPostFX(...)` calls can be +changed at any time. + +## Anti-Aliasing + +Anti-aliasing is a renderer setting: + +```swift +func setAntiAliasing(_ mode: AntiAliasingMode) { + setRendering(.antiAliasing(mode)) +} +``` + +The demo switches between modes such as: + +```swift +setRendering(.antiAliasing(.fxaa)) +setRendering(.antiAliasing(.smaa)) +setRendering(.antiAliasing(.none)) +``` + +This is not a PostFX call because anti-aliasing changes how the render graph +resolves the final image. + +## Debug Views + +The render debug output is also a renderer setting: + +```swift +func setDebugView(_ mode: RenderDebugViewMode) { + if mode == .ssaoBlurred { + setPostFX(.ssao(.enabled(true))) + } + setRendering(.debugView(mode)) +} +``` + +Debug views are useful when tuning quality. For example, SSAO debug output is +only meaningful when SSAO is enabled, so the demo enables it before selecting +`.ssaoBlurred`. + +Return to normal rendering with: + +```swift +setRendering(.debugView(.lit)) +``` + +## Looks And Presets + +The demo defines three curated looks. The neutral look resets to a simple base +state: + +```swift +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))) +``` + +The cinematic and inspection looks use the same API with different values: + +```swift +setPostFX(.preset(.cinematic)) +setPostFX(.bloomThreshold(.enabled(true))) +setPostFX(.bloomComposite(.enabled(true))) +setPostFX(.vignette(.enabled(true))) +``` + +```swift +setPostFX(.preset(.archviz)) +setPostFX(.ssao(.enabled(true))) +setPostFX(.ssao(.quality(.high))) +``` + +Presets are a good starting point. Individual effect calls can then override +specific values. + +## Fine-Grained PostFX Controls + +Each control in the UI maps to a small method that writes directly to the engine +settings. + +Color grading: + +```swift +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))) +``` + +SSAO: + +```swift +setPostFX(.ssao(.enabled(enabled))) +setPostFX(.ssao(.quality(quality))) +setPostFX(.ssao(.radius(radius))) +setPostFX(.ssao(.bias(bias))) +setPostFX(.ssao(.intensity(intensity))) +``` + +Bloom: + +```swift +setPostFX(.bloomThreshold(.enabled(enabled))) +setPostFX(.bloomThreshold(.threshold(threshold))) +setPostFX(.bloomThreshold(.intensity(thresholdIntensity))) +setPostFX(.bloomComposite(.enabled(enabled))) +setPostFX(.bloomComposite(.intensity(compositeIntensity))) +``` + +The pattern is consistent: each effect has an `.enabled(...)` case and separate +cases for its tunable parameters. + +## What To Change First + +Try these changes in `Sources/Demos/RenderingQualityDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Start in cinematic mode | Call `applyCinematicLook()` at the end of `init()`. | +| Prefer sharper AA | Change `.antiAliasing(.fxaa)` to `.antiAliasing(.smaa)`. | +| Inspect SSAO | Call `setRendering(.debugView(.ssaoBlurred))` after enabling SSAO. | +| Make bloom stronger | Increase `.bloomComposite(.intensity(...))`. | +| Disable all extra effects | Use `setPostFX(.preset(.neutral))` and disable effect-specific passes. | + +## Related Documentation + +- [Post Effects](../API/UsingPostFX.md) +- [Rendering System](../API/UsingRenderingSystem.md) +- [Materials](../API/UsingMaterials.md) +- [Color Management](../API/UsingColorManagement.md) + diff --git a/docs/Tutorials/ShowcaseDemo.md b/docs/Tutorials/ShowcaseDemo.md new file mode 100644 index 000000000..fdc78078a --- /dev/null +++ b/docs/Tutorials/ShowcaseDemo.md @@ -0,0 +1,258 @@ +# Showcase Demo + +The Showcase Demo is the broadest demo in the repository. It combines the core +patterns from the focused tutorials: renderer setup, entity lifecycle, asset +loading, tiled scene streaming, static batching, rendering quality controls, +spatial debug overlays, and camera interaction. + +Run it from the repository root: + +```bash +swift run ShowcaseDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/ShowcaseDemo/AppDelegate.swift` | Creates the application window, renderer, HUD, and callbacks. | +| `Sources/Demos/ShowcaseDemo/GameScene.swift` | Bridges UI actions to engine APIs for loading, streaming, batching, debug views, and camera input. | +| `Sources/Demos/ShowcaseDemo/DemoHUD.swift` | Defines the larger demo control surface. | +| `Sources/Demos/ShowcaseDemo/DemoState.swift` | Stores UI state used by the HUD. | + +## What This Demo Teaches + +Use the Showcase Demo after the focused demos. Its purpose is not to introduce +one new system. Its purpose is to show how several engine APIs can be exposed +through one runtime tool. + +The source file includes a useful API map: + +```swift +// Entity lifecycle: createEntity, setEntityName, destroyAllEntities +// Camera/input: createGameCamera, findGameCamera, moveCameraWithInput, orbitCameraAround +// Asset loading: setEntityMeshAsync, setEntityStreamScene +// Performance: setEntityStaticBatchComponent, setBatching, generateBatches, setGeometryStreaming +// Debug overlays: setSpatialDebug +``` + +## Default Scene Objects + +The demo creates a camera and directional light manually: + +```swift +let gameCamera = createEntity() +setEntityName(entityId: gameCamera, name: "Main Camera") +createGameCamera(entityId: gameCamera) + +let light = createEntity() +setEntityName(entityId: light, name: "Directional Light") +createDirLight(entityId: light) + +setDirectionalLight(.active(light)) +setCamera(.active(gameCamera)) +``` + +This is the same entity-driven pattern used throughout the tutorials. + +## Loading Always-Resident Assets + +For normal `.untold` assets, the demo uses `setEntityMeshAsync(...)`: + +```swift +let entity = createEntity() +setEntityName(entityId: entity, name: path) + +setEntityMeshAsync( + entityId: entity, + filename: path, + withExtension: "untold" +) { success in + loadedEntity = success ? entity : nil + loadedContent = success ? .mesh(entity) : .none + setCamera(.active(findGameCamera())) + completion(success) +} +``` + +Before loading a mesh, the demo disables streaming and clears batch artifacts: + +```swift +clearSceneBatches() +setGeometryStreaming(.enabled(false)) +``` + +That keeps always-resident mesh loading separate from tiled scene loading. + +## Loading Tiled Scenes + +For large scenes, the demo creates a root entity and registers a manifest URL: + +```swift +let sceneRoot = createEntity() +setEntityName(entityId: sceneRoot, name: sceneID) + +setEntityStreamScene(entityId: sceneRoot, url: url) { success in + if success { + loadedEntity = nil + loadedContent = .tiledScene(sceneRoot) + setRendering(.environment(.visible(Self.shouldRenderEnvironment(for: sceneID)))) + } + completion(success) +} +``` + +The `LoadedContent` enum tracks whether the current scene is a mesh, a tiled +scene, or empty: + +```swift +private enum LoadedContent { + case none + case mesh(EntityID) + case tiledScene(EntityID) +} +``` + +This lets the demo destroy the right kind of content before loading the next +thing. + +## Scene-Authored Data + +The Showcase Demo can also load scene-authored data: + +```swift +loadSceneAuthored(filename: path, withExtension: "untold", completion: completion) +loadSceneAuthored(url: url, completion: completion) +``` + +Scene-authored data is separate from normal mesh loading. It is used for +exported scene-level settings such as authored environment and color management +data. + +## Batching Controls + +For always-resident static content, the demo can mark the loaded entity for +batching: + +```swift +setEntityStaticBatchComponent(entityId: entity) +UntoldEngine.setBatching(.enabled(true)) +generateBatches() +``` + +When disabled, it turns batching off: + +```swift +UntoldEngine.setBatching(.enabled(false)) +``` + +This manual batching flow is for non-streamed content. Tiled streaming scenes +use their own incremental batching path. + +## Rendering And Debug Controls + +The demo exposes the same rendering quality API used by the Rendering Quality +Demo: + +```swift +setPostFX(.colorGrading(.enabled(enabled))) +setPostFX(.ssao(.enabled(enabled))) +setRendering(.antiAliasing(mode)) +setRendering(.debugView(mode)) +``` + +It also exposes spatial debug overlays: + +```swift +UntoldEngine.setSpatialDebug(.lodLevels(enabled)) +UntoldEngine.setSpatialDebug(.textureStreamingTiers(enabled)) +UntoldEngine.setSpatialDebug(.tileBounds(enabled: enabled)) +``` + +For octree bounds, it uses: + +```swift +UntoldEngine.setSpatialDebug(.octreeLeafBounds(.enabled( + maxLeafNodeCount: 0, + occupiedOnly: occupiedOnly, + colorMode: colorMode +))) +``` + +The Showcase Demo is useful for learning how these engine settings can be wired +to a larger UI without changing the underlying API. + +## Camera Behavior + +The demo supports both free movement and orbit behavior: + +```swift +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 +) +``` + +Right mouse drag can either rotate the camera or orbit around a target: + +```swift +if input.keyState.shiftPressed { + rotateCamera(entityId: camera, pitch: 0, yaw: input.mouseDeltaX, sensitivity: -0.01) +} else { + orbitCameraAround(entityId: camera, uDelta: simd_float2(dx, dy)) +} +``` + +The demo changes orbit behavior depending on what kind of scene is loaded. Large +city scenes use fly/orbit behavior. object-focused scenes orbit around the world +origin. + +## API Pattern To Remember + +The Showcase Demo is mostly a bridge from UI state to engine calls. The useful +pattern is to keep the engine operations small and explicit: + +```swift +loadFile(...) +loadTileScene(...) +setBatching(...) +setAntiAliasing(...) +setRenderDebugView(...) +setSpatialDebug(...) +handleInput() +``` + +Each method wraps one coherent group of engine APIs. That keeps the larger demo +understandable even though it touches many systems. + +## What To Change First + +Try these changes in `Sources/Demos/ShowcaseDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Add a new single asset | Route it through `loadFile(path:completion:)`. | +| Add a new streamed scene | Route it through `loadTileScene(sceneID:url:completion:)`. | +| Change default camera placement | Edit `cameraEye(for:)` or `applyCameraEye(for:)`. | +| Add a new render debug control | Add a UI option that calls `setRenderDebugView(...)`. | +| Add another spatial overlay | Add a wrapper around `setSpatialDebug(...)`. | +| Tune batching behavior | Adjust `setBatching(...)` and related batching settings. | + +## Related Documentation + +- [Usage Examples](../API/UsageExamples.md) +- [Async Loading](../API/UsingAsyncLoading.md) +- [Geometry Streaming](../API/UsingGeometryStreamingSystem.md) +- [Static Batching](../API/UsingStaticBatchingSystem.md) +- [Post Effects](../API/UsingPostFX.md) +- [Spatial Debugger](../API/SpatialDebugger.md) + diff --git a/docs/Tutorials/StarterDemo.md b/docs/Tutorials/StarterDemo.md new file mode 100644 index 000000000..af03d5172 --- /dev/null +++ b/docs/Tutorials/StarterDemo.md @@ -0,0 +1,291 @@ +# Starter Demo + +The Starter Demo is the smallest complete Untold Engine application in the +repository. It creates a macOS window, creates an `UntoldRenderer`, connects the +renderer to a SwiftUI `SceneView`, registers a camera and a light, creates one +renderable cube entity, and updates that cube every frame. + +Run it from the repository root: + +```bash +swift run StarterDemo +``` + +## Source Files + +| File | Role | +| --- | --- | +| `Sources/Demos/StarterDemo/main.swift` | Starts the macOS application and installs the app delegate. | +| `Sources/Demos/StarterDemo/AppDelegate.swift` | Creates the window, renderer, `SceneView`, and frame callbacks. | +| `Sources/Demos/StarterDemo/GameScene.swift` | Configures engine state, creates scene entities, handles input, and updates the scene. | +| `Sources/Demos/DemoUtils/DemoUtils.swift` | Provides small demo helpers around the public engine APIs. | + +## Application Entry Point + +`main.swift` is intentionally minimal: + +```swift +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() +``` + +The engine work starts in `AppDelegate`. This keeps platform bootstrapping +separate from scene logic. In your own application, the surrounding app lifecycle +may be different, but the engine pattern is the same: create a renderer, present +a scene view, and register update/input callbacks. + +## Renderer Setup + +The demo creates the renderer with: + +```swift +guard let renderer = UntoldRenderer.create() else { + print("Failed to initialize UntoldRenderer.") + NSApp.terminate(nil) + return +} +``` + +`UntoldRenderer.create()` initializes the engine renderer for a standard macOS +Metal view. After creation, the demo passes the renderer into `SceneView`: + +```swift +SceneView(renderer: renderer) +``` + +`SceneView` owns the SwiftUI-facing view layer. The renderer owns the Metal +render loop and engine frame execution. + +## Frame Callbacks + +The renderer calls back into the demo for per-frame scene logic: + +```swift +renderer.setupCallbacks( + gameUpdate: { [weak self] deltaTime in + self?.gameScene.update(deltaTime: deltaTime) + }, + handleInput: { [weak self] in + self?.gameScene.handleInput() + } +) +``` + +Use `gameUpdate` for simulation, animation, and other time-based scene changes. +Use `handleInput` for keyboard, mouse, touch, gamepad, or XR input state. + +The important API shape is: + +- `gameUpdate` receives `deltaTime`, so movement and animation can be frame-rate independent. +- `handleInput` runs separately, so input polling stays grouped in one place. +- The scene object does not own the renderer. It exposes update and input methods that the renderer calls. + +## Engine Configuration + +The Starter Demo calls: + +```swift +configureDemoEngine(registerKeyboard: true) +``` + +This helper lives in `DemoUtils`. It wraps the engine setup that most demos need: + +```swift +gameMode = true +setSceneReady(false) +setRendering(.postProcessing(.enabled)) +setRendering(.antiAliasing(.fxaa)) +setRendering(.environment(.ibl(true))) +setRendering(.environment(.visible(false))) +InputSystem.shared.registerKeyboardEvents() +InputSystem.shared.registerMouseEvents() +``` + +For your own project, the key idea is that engine state is configured through +small API calls: + +- `gameMode = true` enables game runtime behavior. +- `setSceneReady(false)` prevents scene-dependent logic from running while setup or loading is incomplete. +- `setRendering(...)` changes renderer features such as post-processing, anti-aliasing, and environment lighting. +- `InputSystem.shared.registerKeyboardEvents()` and `InputSystem.shared.registerMouseEvents()` connect platform input events to the engine input system. + +## Camera And Light + +The demo creates its camera with: + +```swift +makeDemoCamera( + name: "Main Camera", + eye: simd_float3(0.0, 2.0, 6.0), + target: simd_float3(0.0, 0.0, 0.0), + orbitOffset: 5.0 +) +``` + +The helper expands to the normal camera API: + +```swift +let camera = createEntity() +setEntityName(entityId: camera, name: name) +createGameCamera(entityId: camera) +cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0)) +setOrbitOffset(entityId: camera, uTargetOffset: offset) +setCamera(.active(camera)) +``` + +This shows an important Untold Engine pattern: cameras are entities. You create +an entity, attach the camera behavior, aim it, and make it active. + +The light uses the same pattern: + +```swift +let sun = createEntity() +setEntityName(entityId: sun, name: "Key Light") +createDirLight(entityId: sun) +rotateTo(entityId: sun, angle: -45.0, axis: simd_float3(1, 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)) +``` + +Lights are also entities. The entity gives the light a transform, and the light +component controls how it contributes to the renderer. + +## Creating A Renderable Entity + +The cube is created in `createStarterObject()`: + +```swift +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: simd_float3(0.0, 0.0, 0.0)) +updateMaterialColor(entityId: entity, color: Color(red: 0.95, green: 0.42, blue: 0.18)) +setSceneReady(true) +``` + +This is the basic ECS workflow: + +1. `createEntity()` allocates an entity ID. +2. `setEntityName(...)` gives the entity a readable debug name. +3. `setEntityMeshDirect(...)` attaches renderable mesh data directly. +4. `translateTo(...)` places the entity in the scene. +5. `updateMaterialColor(...)` changes its material appearance. +6. `setSceneReady(true)` marks the scene ready after required setup has completed. + +The Starter Demo uses `BasicPrimitives.createCube(...)`, so it does not require +external assets. When you want to load exported content instead, use +`setEntityMeshAsync(...)`: + +```swift +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) +} +``` + +Apply transforms inside the completion handler when loading asynchronously. That +keeps placement tied to successful mesh registration. + +## Updating The Scene + +The demo rotates the cube each frame: + +```swift +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) + ) + +} +``` + +`rotateBy(...)` applies an incremental transform to the entity. Multiplying by +`deltaTime` keeps the rotation speed stable when frame rate changes. + +The guard is also part of the engine pattern: + +- Skip scene updates when the entity has not been created. +- Skip game logic when `gameMode` is disabled. +- Use `isSceneReady()` in input or gameplay paths that depend on loaded content. + +## Handling Input + +The demo reads keyboard and mouse state from the shared input system: + +```swift +let input = InputSystem.shared +``` + +Camera movement uses the high-level camera helper: + +```swift +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: 4.0, + deltaTime: 1.0 / 60.0 +) +``` + +Right mouse drag orbits the active camera: + +```swift +if input.keyState.rightMousePressed { + setOrbitOffset(entityId: camera, uTargetOffset: 5.0) + orbitCameraAround( + entityId: camera, + uDelta: simd_float2(input.mouseDeltaX, input.mouseDeltaY) + ) +} +``` + +The important API lesson is that input state and camera behavior are separate. +`InputSystem` tells you what the user is doing. Camera APIs decide how that input +changes the active camera entity. + +## What To Change First + +Try these changes in `Sources/Demos/StarterDemo/GameScene.swift`: + +| Goal | API To Change | +| --- | --- | +| Move the cube | Change the `translateTo(...)` position. | +| Change the cube size | Change `BasicPrimitives.createCube(extent:)`. | +| Change its color | Change `updateMaterialColor(...)`. | +| Rotate around another axis | Change the `axis` passed to `rotateBy(...)`. | +| Move the starting camera | Change the `eye` position passed to `makeDemoCamera(...)`. | +| Load your own asset | Replace `setEntityMeshDirect(...)` with `setEntityMeshAsync(...)`. | + +## Related Documentation + +- [Registration System](../API/UsingRegistrationSystem.md) +- [Transform System](../API/UsingTransformSystem.md) +- [Camera System](../API/UsingCameraSystem.md) +- [Input System](../API/UsingInputSystem.md) +- [Rendering System](../API/UsingRenderingSystem.md) +- [Materials](../API/UsingMaterials.md) diff --git a/docs/Tutorials/index.md b/docs/Tutorials/index.md new file mode 100644 index 000000000..19c7634c1 --- /dev/null +++ b/docs/Tutorials/index.md @@ -0,0 +1,49 @@ +# Tutorials + +The tutorials use the demos in `Sources/Demos` as small, runnable examples of +the Untold Engine API. Each tutorial focuses on the engine calls that matter for +building an application: creating a renderer, registering entities, loading or +creating renderable content, configuring cameras and lights, handling input, and +updating the scene every frame. + +The demo READMEs explain how to run each executable. These tutorials explain how +the demos are built and how the same API calls transfer into your own project. + +## Learning Path + +| Tutorial | Run Command | Main API Focus | +| --- | --- | --- | +| [Starter Demo](StarterDemo.md) | `swift run StarterDemo` | Renderer setup, frame callbacks, scene readiness, entities, meshes, transforms, camera movement, mouse input. | +| [Lighting Demo](LightingDemo.md) | `swift run LightingDemo` | Light entities, directional lights, point lights, spot lights, area lights, light color, intensity, and runtime controls. | +| [Rendering Quality Demo](RenderingQualityDemo.md) | `swift run RenderingQualityDemo` | Rendering settings, anti-aliasing, debug views, PostFX presets, and runtime quality controls. | +| [Exporter Pipeline Demo](ExporterPipelineDemo.md) | `swift run ExporterPipelineDemo` | Loading `.untold` assets, exported animation clips, validation metadata, and authored asset workflows. | +| [Interaction / Gameplay Demo](InteractionGameplayDemo.md) | `swift run InteractionGameplayDemo` | Gameplay-style input, animation switching, physics pause/resume, parented entities, and update-loop behavior. | +| [Large Scene Streaming Demo](LargeSceneStreamingDemo.md) | `swift run LargeSceneStreamingDemo` | Tiled scene manifests, geometry streaming, LOD, static batching, cache budgets, and streaming diagnostics. | +| [Showcase Demo](ShowcaseDemo.md) | `swift run ShowcaseDemo` | A combined demonstration of multiple systems after you understand the focused demos. | + +## How To Use These Tutorials + +Start with the Starter Demo even if your goal is XR, streaming, or custom +rendering. It shows the basic shape of an Untold Engine application without +external assets or large-scene systems. + +After that, choose the demo that matches the system you want to learn. The +tutorials are meant to be read with the source files open: + +- `Sources/Demos//main.swift` +- `Sources/Demos//AppDelegate.swift` +- `Sources/Demos//GameScene.swift` + +Most demos also use shared setup helpers from `Sources/Demos/DemoUtils`. Those +helpers keep the demo files short, but the tutorials call out the underlying +engine APIs so you can use them directly in your own app. + +## Related Documentation + +- [Getting Started](../API/GettingStarted.md) +- [Usage Examples](../API/UsageExamples.md) +- [Registration System](../API/UsingRegistrationSystem.md) +- [Transform System](../API/UsingTransformSystem.md) +- [Camera System](../API/UsingCameraSystem.md) +- [Input System](../API/UsingInputSystem.md) +- [Rendering System](../API/UsingRenderingSystem.md) diff --git a/mkdocs.yml b/mkdocs.yml index bcd7af873..0ca272a1b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,15 @@ markdown_extensions: nav: - Introduction: index.md + - Tutorials: + - Overview: Tutorials/index.md + - Starter Demo: Tutorials/StarterDemo.md + - Lighting Demo: Tutorials/LightingDemo.md + - Rendering Quality Demo: Tutorials/RenderingQualityDemo.md + - Exporter Pipeline Demo: Tutorials/ExporterPipelineDemo.md + - Interaction / Gameplay Demo: Tutorials/InteractionGameplayDemo.md + - Large Scene Streaming Demo: Tutorials/LargeSceneStreamingDemo.md + - Showcase Demo: Tutorials/ShowcaseDemo.md - API: - Getting Started: API/GettingStarted.md - Untold Engine CLI: API/UsingUntoldEngineCLI.md