diff --git a/Examples/RenderingExtensions/ApplicationLocal/README.md b/Examples/RenderingExtensions/ApplicationLocal/README.md new file mode 100644 index 00000000..cd0d1428 --- /dev/null +++ b/Examples/RenderingExtensions/ApplicationLocal/README.md @@ -0,0 +1,144 @@ +# Model-Surface Argument Buffer Sample + +This sample is a complete Rendering Extension that draws selected model +entities with a per-entity tint. It demonstrates an extension-owned shader +library, argument layout, model-surface pipeline, staged pass, and argument +encoding. + +This is an application-local example, not a requirement that extensions live in +the application target. The same `RenderExtension` implementation can live in a +framework or Swift package. For a complete distributable package with a plugin +manifest, bundled metallib, and public installation entry point, see the +[`SwiftPackagePlugin`](../SwiftPackagePlugin/README.md) fixture. + +Application-local extensions and package plugins can be active together. Use +globally unique extension and artifact IDs, register both before renderer +creation, and do not attempt to share owner-private resources between them. + +## Mental Model + +A Rendering Extension declares what it owns, then adds one or more passes to +the render graph. Resource declarations are recorded immediately and allocated +when Metal and any required viewport are available. Shader and pipeline hooks +are materialized when Metal is ready. The engine invokes `buildGraph` when +constructing the graph and executes the pass closures during rendering. + +Only `id` and `buildGraph` are required by `RenderExtension`. The other hooks +have default no-op implementations; implement only the capabilities the +extension uses. + +| Extension member | When to implement it | +| --- | --- | +| `id` | Always. Use a globally unique, stable owner ID. | +| `registerShaderLibraries` | When shaders come from an application, framework, package, or custom metallib. | +| `registerArgumentBuffers` | When a model-surface fragment shader reads extension textures, samplers, or buffers. | +| `registerPipelines` | When the extension uses a custom render pipeline. | +| `registerComputePipelines` | When the extension dispatches compute work. | +| `registerResources` | When the extension owns render textures or buffers. | +| `buildGraph` | Always. Add the passes that perform the extension's work and declare every extension resource they access. | + +For a model-surface extension, implement it in this order: + +1. Define an opt-in component and any per-entity settings. +2. Write a fragment shader using `UntoldModelSurfaceExtensionArguments`. +3. Choose namespaced IDs for the extension, library, pipeline, layout, and pass. +4. Register the shader library, argument layout, and model-surface pipeline. +5. Add a graph pass, declare any extension resource usage, and call + `drawModelSurfaceEntities` from its pass closure. +6. Bind per-entity values through `bindArguments`, using IDs that match Metal. +7. Register the extension before renderer creation and attach its component to + each entity it should draw. + +This tint sample does not declare pass resources because it uploads value bytes +and uses engine-owned model targets. A pass that reads an extension texture or +buffer must include it in `builder.addPass(..., resources:)`; undeclared lookups +return `nil`. + +The sample's data flow is: + +```text +TintSurfaceComponent.color + -> bindArguments / buffer0 + -> UntoldModelSurfaceExtensionArguments.buffer0 + -> tintSurfaceFragment +``` + +## Xcode Application Target + +Add `TintSurface.metal` to the application's Compile Sources build phase. Xcode +will include it in the application's default Metal library. The default +initializer loads that library from `Bundle.main`: + +```swift +setRendering(.extensions(.register(TintSurfaceRenderExtension()))) +``` + +For a framework target, pass that framework's bundle instead: + +```swift +TintSurfaceRenderExtension( + shaderBundle: Bundle(for: TintSurfaceRenderExtension.self) +) +``` + +## Swift Package Target + +Swift Package Manager copies Metal source resources without compiling them, so +compile the shader for each deployment SDK and place the result beside +`TintSurface.metal`. For macOS: + +```sh +xcrun -sdk macosx metal -c \ + -I /Sources/UntoldEngineShaderSupport/include \ + Shaders/TintSurface.metal -o Shaders/TintSurface.air +xcrun -sdk macosx metallib \ + Shaders/TintSurface.air -o Shaders/TintSurface.metallib +``` + +Copy the shader directory as a resource: + +```swift +.target( + name: "TintSurfaceExtension", + dependencies: [ + .product(name: "UntoldEngine", package: "UntoldEngine"), + .product(name: "UntoldEngineShaderSupport", package: "UntoldEngine"), + ], + resources: [ + .copy("Shaders"), + ] +) +``` + +Use the corresponding SDK and output library when building for iOS or visionOS. +The shader-support product supplies the shared ABI header; the explicit include +path makes it available to command-line Metal compilation. + +Pass the package-only `Bundle.module` from code in that package target. The +engine resolves the metallib relative to that bundle: + +```swift +let tintExtension = TintSurfaceRenderExtension( + shaderBundle: .module, + metallibResource: "TintSurface", + metallibSubdirectory: "Shaders" +) +setRendering(.extensions(.register(tintExtension))) +``` + +Register the extension before renderer creation, then attach its component to +each entity that should use the pass: + +```swift +let model = createEntity() +setEntityMesh(entityId: model, filename: "sample", withExtension: "untold") +registerComponent(entityId: model, componentType: TintSurfaceComponent.self) +getEntityComponent( + entityId: model, + componentType: TintSurfaceComponent.self +)?.color = SIMD4(0.15, 0.65, 1.0, 0.8) +``` + +The extension uses `buffer0`, but that ID is local to its argument buffer. +Another extension can independently use `buffer0`; each draw encodes and binds +a different argument buffer at the engine-owned outer slot. diff --git a/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal b/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal new file mode 100644 index 00000000..c964f4c5 --- /dev/null +++ b/Examples/RenderingExtensions/ApplicationLocal/Shaders/TintSurface.metal @@ -0,0 +1,21 @@ +#include +#include + +using namespace metal; + +struct TintSurfaceUniforms { + float4 color; +}; + +fragment float4 tintSurfaceFragment( + UntoldModelSurfaceVertexOut in [[stage_in]], + constant UntoldModelSurfaceExtensionArguments &arguments + [[buffer(UntoldModelSurfaceExtensionArgumentBufferIndex)]] +) { + constant TintSurfaceUniforms &uniforms = + *reinterpret_cast(arguments.buffer0); + return float4( + uniforms.color.rgb * uniforms.color.a, + uniforms.color.a + ); +} diff --git a/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift b/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift new file mode 100644 index 00000000..052bc16e --- /dev/null +++ b/Examples/RenderingExtensions/ApplicationLocal/TintSurfaceRenderExtension.swift @@ -0,0 +1,149 @@ +import Foundation +import simd +import UntoldEngine + +/// Opt-in component that marks an entity for the tint surface pass. +/// +/// A model is drawn by this extension only after this component is attached to +/// the entity. Extension-specific settings also belong on this component so +/// they can be encoded separately for each entity. +public final class TintSurfaceComponent: Component { + /// Source tint whose RGB output is premultiplied by the fragment shader. + public var color = SIMD4(1.0, 1.0, 1.0, 1.0) + + /// Components require a default initializer so the ECS can create them. + public required init() {} +} + +/// Complete model-surface Rendering Extension using one argument-buffer value. +/// +/// The implementation has four responsibilities: +/// 1. Register the shader library that owns the custom fragment function. +/// 2. Declare the argument IDs shared by Swift and Metal. +/// 3. Create a model-surface pipeline using that shader and layout. +/// 4. Add a graph pass that selects entities and encodes per-entity arguments. +public final class TintSurfaceRenderExtension: RenderExtension, @unchecked Sendable { + /// Stable owner ID used by the engine to register and remove this extension. + public let id = "sample.tintSurface" + + // Registry IDs are global, so namespace every ID owned by the extension. + private let shaderLibraryID: RenderShaderLibraryID = "sample.tintSurface.shaders" + private let pipelineID: RenderPipelineType = "sample.tintSurface.pipeline" + private let argumentLayoutID = "sample.tintSurface.arguments" + private let passID = "sample.tintSurface.draw" + private let shaderBundle: Bundle + private let metallibResource: String? + private let metallibSubdirectory: String? + + /// Creates an extension that loads a bundle's default Metal library. + /// + /// Use the default for an Xcode application target. A framework should pass + /// its framework bundle explicitly. + public init(shaderBundle: Bundle = .main) { + self.shaderBundle = shaderBundle + metallibResource = nil + metallibSubdirectory = nil + } + + /// Creates an extension that loads a bundled precompiled Metal library. + /// + /// Use this initializer when a Swift package bundles a `.metallib` resource. + public init( + shaderBundle: Bundle, + metallibResource: String, + metallibSubdirectory: String? = nil + ) { + self.shaderBundle = shaderBundle + self.metallibResource = metallibResource + self.metallibSubdirectory = metallibSubdirectory + } + + /// Registers the library containing `tintSurfaceFragment`. + /// + /// This hook is optional for extensions that use only engine shaders. This + /// sample implements it because its fragment function is extension-owned. + public func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + if let metallibResource { + registry.registerLibrary( + shaderLibraryID, + bundle: shaderBundle, + resource: metallibResource, + subdirectory: metallibSubdirectory + ) + } else { + registry.registerDefaultLibrary(shaderLibraryID, bundle: shaderBundle) + } + } + + /// Declares the local argument IDs used by the extension fragment shader. + /// + /// `buffer0` here must match `arguments.buffer0` in `TintSurface.metal`. + /// Other extensions may also use `buffer0` because argument IDs are local + /// to each extension draw. + public func registerArgumentBuffers(_ registry: RenderExtensionArgumentBufferRegistry) { + registry.registerArgumentBuffer( + RenderExtensionArgumentBufferDescriptor( + id: argumentLayoutID, + buffers: [ + RenderExtensionArgumentBuffer( + id: RenderExtensionModelSurfaceArgument.buffer0 + ), + ] + ) + ) + } + + /// Creates the pipeline that combines the engine model vertex shader with + /// the extension's fragment shader. + /// + /// Validation checks that the fragment shader uses the engine-owned outer + /// argument-buffer slot and that this layout was registered. + public func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerModelSurfacePipeline( + pipelineID, + fragmentShader: "tintSurfaceFragment", + fragmentShaderLibrary: .registered(shaderLibraryID), + depthEnabled: true, + blendMode: .alphaPremultiplied, + name: "Tint Surface", + validation: .warn(argumentLayoutID: argumentLayoutID) + ) + } + + /// Adds the extension's required render work to the engine graph. + /// + /// `buildGraph` is the only required protocol function besides `id`. The + /// engine calls it while constructing the graph; the pass closure executes + /// later during each frame. + public func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .beforePostProcess) { [pipelineID, argumentLayoutID] context in + // The helper supplies engine model bindings and draws only entities + // carrying TintSurfaceComponent. bindArguments runs once per entity. + context.drawModelSurfaceEntities( + pipeline: pipelineID, + matching: [TintSurfaceComponent.self], + label: "Tint Surface", + argumentLayoutID: argumentLayoutID, + bindArguments: { arguments, entityID, _ in + guard let tint = getEntityComponent( + entityId: entityID, + componentType: TintSurfaceComponent.self + ) else { + return + } + + var color = tint.color + // The Swift ID must match the Metal member read by the + // fragment shader. The engine owns the outer buffer slot. + arguments.setBytes( + &color, + id: RenderExtensionModelSurfaceArgument.buffer0 + ) + } + ) + } + } +} diff --git a/Examples/RenderingExtensions/README.md b/Examples/RenderingExtensions/README.md new file mode 100644 index 00000000..35e5be18 --- /dev/null +++ b/Examples/RenderingExtensions/README.md @@ -0,0 +1,50 @@ +# Rendering Extension Examples + +These examples demonstrate the two supported ways to integrate Rendering +Extensions with Untold Engine. + +## Application-Local + +[`ApplicationLocal`](ApplicationLocal/README.md) contains a focused model-surface +extension and Metal shader intended to be added directly to an application or +framework target. + +Use it to learn: + +- the `RenderExtension` authoring hooks; +- direct registration through `setRendering`; +- model-surface argument-buffer registration and binding; +- attaching an extension component to an entity. + +## Swift Package Plugin + +[`SwiftPackagePlugin`](SwiftPackagePlugin/README.md) is a complete, independently +buildable Swift package and an automated acceptance fixture. + +Because `SwiftPackagePlugin` contains its own `Package.swift`, Xcode treats it as +a separate nested package and may not show its folder in the parent UntoldEngine +package navigator. The files are still available at +`Examples/RenderingExtensions/SwiftPackagePlugin`. Open that folder's +`Package.swift` separately, add it as a local package dependency, or include both +packages in an Xcode workspace. Do not add its source files directly to the +UntoldEngine target. + +Use it to learn: + +- package dependency and resource setup; +- precompiled metallib bundling; +- plugin manifests and public installation entry points; +- atomic installation of package-owned extensions; +- consumer-side component configuration. + +> The package fixture uses water-themed names and placeholder shaders to exercise +> the complete API. It is not a real or production-ready water renderer. + +Both examples use the same underlying `RenderExtension` API. Applications can +use application-local extensions and package plugins at the same time as long as +their globally registered artifact IDs are unique. + +For the authoring guide, see +[Using Rendering Extensions](../../docs/API/UsingRenderingExtensions.md). For +internal behavior, see +[Rendering Extensions Architecture](../../docs/Architecture/RenderingExtensions.md). diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift b/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift new file mode 100644 index 00000000..5b32f522 --- /dev/null +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Package.swift @@ -0,0 +1,54 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "WaterRenderPlugin", + platforms: [ + .macOS(.v14), + ], + products: [ + .library(name: "WaterRenderPlugin", targets: ["WaterRenderPlugin"]), + ], + dependencies: [ + // This path works only while this fixture remains nested at + // Examples/RenderingExtensions/SwiftPackagePlugin in the UntoldEngine + // repository. + // + // If you copy this package elsewhere for local development, replace it + // with the absolute or relative path to your UntoldEngine checkout: + // .package(path: "/path/to/UntoldEngine") + // + // A distributed package should use the canonical repository URL and a + // compatible release requirement instead: + // .package(url: "https://example.com/UntoldEngine.git", from: "0.13.3") + .package(path: "../../.."), + ], + targets: [ + .target( + name: "WaterRenderPlugin", + dependencies: [ + .product(name: "UntoldEngine", package: "UntoldEngine"), + ], + exclude: ["Shaders"], + resources: [ + // SwiftPM does not compile Metal source in this fixture. Bundle + // one precompiled metallib for every platform the package supports. + .copy("Resources/WaterRenderPlugin.metallib"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "WaterRenderPluginTests", + dependencies: [ + "WaterRenderPlugin", + .product(name: "UntoldEngine", package: "UntoldEngine"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + ] +) diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/README.md b/Examples/RenderingExtensions/SwiftPackagePlugin/README.md new file mode 100644 index 00000000..97191550 --- /dev/null +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/README.md @@ -0,0 +1,149 @@ +# Water Render Plugin Fixture + +> **Example fixture only:** This package is not a real or production-ready water +> renderer. Its shaders and visual behavior are intentionally minimal placeholders +> used to demonstrate and test how a third-party Rendering Extension is packaged, +> registered, validated, and distributed with Swift Package Manager. + +This nested package is the acceptance fixture for distributing an Untold Engine +rendering extension independently from the engine package. It contains: + +- a `RenderExtensionPlugin` manifest and public registration entry point; +- compute and model-surface extension implementations; +- declared texture resources and compute/render pipelines; +- a model-surface argument-buffer layout; +- Metal source plus a bundled, precompiled macOS `.metallib`; +- consumer-side tests that import only public engine and plugin APIs. + +## Opening in Xcode + +This directory contains its own `Package.swift`, so Xcode treats it as a separate +package boundary and may hide it from the parent UntoldEngine package navigator. +Open this directory's `Package.swift` separately, add the directory as a local +package dependency, or include both packages in an Xcode workspace. Do not add +the package's source files directly to the UntoldEngine target. + +## Engine Dependency + +The fixture's `Package.swift` uses: + +```swift +.package(path: "../../..") +``` + +That path is relative to this package directory and works only while the fixture +is nested inside the UntoldEngine repository. It is not resolved relative to the +consuming application. If you copy the package elsewhere, replace it with the +path to your local engine checkout: + +```swift +.package(path: "/path/to/UntoldEngine") +``` + +For distribution, use the canonical UntoldEngine repository URL and a compatible +version requirement instead. The consuming application and plugin should resolve +the same UntoldEngine package source and version. + +## Consumer Integration + +Add the `WaterRenderPlugin` library product to the application target, import the +module, and install it before renderer creation: + +```swift +import WaterRenderPlugin + +let result = registerWaterRenderPlugin() +``` + +`registerWaterRenderPlugin()` installs every extension in the plugin atomically. +The package remains a compile-time SwiftPM dependency; this is not runtime module +discovery or dynamic loading from an arbitrary folder. + +Install the plugin once during application startup, before renderer creation, +and handle the installation result: + +```swift +import UntoldEngine +import WaterRenderPlugin + +func installWaterRendering() -> Bool { + switch registerWaterRenderPlugin() { + case .installed: + return true + case .replaced: + // A plugin with the same manifest ID was already installed and was + // replaced atomically by this version. + return true + case let .rejected(failure): + print("Water plugin validation errors:", failure.validationErrors) + print("Water plugin artifact conflicts:", failure.artifactConflicts) + print("Water plugin graph errors:", failure.graphValidationErrors) + return false + } +} +``` + +After installation, create a model entity and attach the public component +exported by the package. The plugin's surface pass selects only entities carrying +this component: + +```swift +if installWaterRendering() { + let water = createEntity() + setEntityMeshDirect( + entityId: water, + meshes: BasicPrimitives.createPlane(), + assetName: "water" + ) + registerComponent(entityId: water, componentType: WaterSurfaceComponent.self) + + if let surface = getEntityComponent( + entityId: water, + componentType: WaterSurfaceComponent.self + ) { + surface.tint = SIMD4(0.08, 0.38, 0.62, 1.0) + surface.roughness = 0.08 + surface.waveStrength = 0.18 + } +} +``` + +Do not also register `WaterSurfaceRenderExtension` through `setRendering`. The +plugin entry point already creates it with the package-only `Bundle.module` and +registers it under plugin lifecycle management. + +## Package Plugin Versus Application-Local Extension + +This package and the +[`ApplicationLocal`](../ApplicationLocal/README.md) +sample use the same `RenderExtension` hooks and argument-buffer APIs. The tint +sample shows the smallest application-local implementation. This fixture adds +the pieces required for third-party distribution: + +- a SwiftPM library product and `Bundle.module` resource access; +- a bundled precompiled metallib; +- a versioned `RenderExtensionPluginManifest`; +- a factory that can supply one or more extensions; +- one public function that installs or rolls back the complete plugin. + +Applications can use both forms simultaneously. Register application-local +extensions with `setRendering(.extensions(.register(...)))` and install package +plugins through `RenderExtensionPluginRegistry` or their public entry points. +Every extension and artifact ID must remain globally unique. Extension-owned +resources remain private and cannot be accessed by another provider. + +Run the fixture independently from the engine repository root: + +```sh +swift test --package-path Examples/RenderingExtensions/SwiftPackagePlugin +``` + +Rebuild the bundled macOS shader library after changing the Metal source: + +```sh +Examples/RenderingExtensions/SwiftPackagePlugin/Scripts/build-metallib.sh +``` + +A production package should build and bundle a metallib for every platform and +SDK it supports. This fixture is macOS-only so the checked-in binary has one +unambiguous target. diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Scripts/build-metallib.sh b/Examples/RenderingExtensions/SwiftPackagePlugin/Scripts/build-metallib.sh new file mode 100755 index 00000000..b3b66527 --- /dev/null +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Scripts/build-metallib.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fixture_root=$(CDPATH= cd -- "$script_dir/.." && pwd) +engine_root=$(CDPATH= cd -- "$fixture_root/../../.." && pwd) +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/WaterRenderPlugin.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT + +source_file="$fixture_root/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal" +output_file="$fixture_root/Sources/WaterRenderPlugin/Resources/WaterRenderPlugin.metallib" +include_dir="$engine_root/Sources/UntoldEngineShaderSupport/include" + +xcrun -sdk macosx metal \ + -c "$source_file" \ + -I "$include_dir" \ + -fmodules-cache-path="$work_dir/ModuleCache" \ + -o "$work_dir/WaterRenderPlugin.air" +xcrun -sdk macosx metallib \ + "$work_dir/WaterRenderPlugin.air" \ + -o "$output_file" diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Resources/WaterRenderPlugin.metallib b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Resources/WaterRenderPlugin.metallib new file mode 100644 index 00000000..273ef1aa Binary files /dev/null and b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Resources/WaterRenderPlugin.metallib differ diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal new file mode 100644 index 00000000..13b909d8 --- /dev/null +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/Shaders/WaterRenderPlugin.metal @@ -0,0 +1,55 @@ +#include +#include + +using namespace metal; + +kernel void waterFixtureTextureKernel( + texture2d waterColor [[texture(0)]], + texture2d waterNormal [[texture(1)]], + uint2 gid [[thread_position_in_grid]] +) { + if (gid.x >= waterColor.get_width() || gid.y >= waterColor.get_height()) { + return; + } + + float2 size = float2(waterColor.get_width(), waterColor.get_height()); + float2 uv = (float2(gid) + 0.5) / size; + float wave = sin(uv.x * 42.0) * cos(uv.y * 31.0); + float normalizedWave = wave * 0.5 + 0.5; + float3 color = mix(float3(0.01, 0.12, 0.22), float3(0.10, 0.55, 0.68), normalizedWave); + float3 normal = normalize(float3(-wave * 0.18, 1.0, wave * 0.12)); + + waterColor.write(float4(color, 1.0), gid); + waterNormal.write(float4(normal * 0.5 + 0.5, normalizedWave), gid); +} + +struct WaterFixtureSurfaceUniforms { + float4 tint; + float roughness; + float waveStrength; + float padding0; + float padding1; +}; + +fragment float4 waterFixtureSurfaceFragment( + UntoldModelSurfaceVertexOut in [[stage_in]], + constant UntoldModelSurfaceExtensionArguments &arguments + [[buffer(UntoldModelSurfaceExtensionArgumentBufferIndex)]] +) { + constexpr sampler surfaceSampler( + min_filter::linear, + mag_filter::linear, + mip_filter::none, + address::repeat + ); + constant WaterFixtureSurfaceUniforms &uniforms = + *reinterpret_cast(arguments.buffer0); + + float2 uv = float2(in.uvCoords.x, 1.0 - in.uvCoords.y); + float4 color = arguments.texture0.sample(surfaceSampler, uv); + float4 normalWave = arguments.texture1.sample(surfaceSampler, uv); + float highlight = normalWave.a * uniforms.waveStrength * (1.0 - uniforms.roughness); + float3 tinted = mix(color.rgb, uniforms.tint.rgb, 0.45) + highlight; + float alpha = clamp(uniforms.tint.a, 0.0, 1.0); + return float4(tinted * alpha, alpha); +} diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift new file mode 100644 index 00000000..79eaed09 --- /dev/null +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Sources/WaterRenderPlugin/WaterRenderPlugin.swift @@ -0,0 +1,283 @@ +import Foundation +import Metal +import UntoldEngine + +/// Stable IDs shared by plugin registration, graph declarations, and shaders. +/// +/// IDs occupy engine-wide registries, so a production provider should namespace +/// every value with a reverse-domain or similarly unique package prefix. +public enum WaterRenderPluginContract { + public static let pluginID = "com.untold.fixture.water" + public static let extensionID = "com.untold.fixture.water.surface" + public static let shaderLibraryID: RenderShaderLibraryID = "com.untold.fixture.water.shaders" + public static let computePipelineID: ComputePipelineType = "com.untold.fixture.water.generate" + public static let surfacePipelineID: RenderPipelineType = "com.untold.fixture.water.surface" + public static let argumentLayoutID = "com.untold.fixture.water.surface.arguments" + public static let colorTextureID: RenderTextureResourceID = "com.untold.fixture.water.color" + public static let normalTextureID: RenderTextureResourceID = "com.untold.fixture.water.normal" +} + +/// Package-level manifest and factory for all Rendering Extensions in this module. +/// +/// A plugin is the distribution and lifecycle wrapper. The rendering work still +/// lives in ordinary `RenderExtension` implementations returned by +/// `makeRenderExtensions()`. +public struct WaterRenderPlugin: RenderExtensionPlugin { + /// Locates the precompiled shader library bundled by this Swift package. + /// + /// `Bundle.module` is generated by SwiftPM and is accessible only from code + /// inside this package target. Applications should not try to access it. + public static var bundledMetallibURL: URL? { + Bundle.module.url(forResource: "WaterRenderPlugin", withExtension: "metallib") + } + + /// Identity and compatibility metadata validated before installation. + public let manifest = RenderExtensionPluginManifest( + id: WaterRenderPluginContract.pluginID, + displayName: "Water Render Plugin Fixture", + version: RenderExtensionPluginVersion(major: 1, minor: 0, patch: 0) + ) + + /// Creates the package plugin. Registration occurs only when it is installed. + public init() {} + + /// Creates every extension owned by this plugin installation. + /// + /// Installation is atomic: if any returned extension fails registration, + /// the engine rolls back the complete plugin instead of leaving a partial set. + public func makeRenderExtensions() -> [any RenderExtension] { + [WaterSurfaceRenderExtension(shaderBundle: .module)] + } +} + +/// Installs every Rendering Extension supplied by this package. +/// +/// The consuming application imports `WaterRenderPlugin` and calls this function +/// before renderer creation. Inspect the returned result to handle manifest, +/// shader, pipeline, resource, or graph registration failures. +@discardableResult +public func registerWaterRenderPlugin() -> RenderExtensionPluginInstallationResult { + RenderExtensionPluginRegistry.shared.install(WaterRenderPlugin()) +} + +/// Opt-in component identifying entities rendered by the water surface pass. +/// +/// Public components let the consuming application configure package-owned +/// rendering behavior without exposing the extension's internal implementation. +public final class WaterSurfaceComponent: Component, @unchecked Sendable { + public var tint = SIMD4(0.10, 0.45, 0.65, 1.0) + public var roughness: Float = 0.08 + public var waveStrength: Float = 0.18 + + public required init() {} +} + +/// CPU representation of the argument-buffer value read by the fragment shader. +/// Its field order and alignment must match `WaterSurfaceUniforms` in Metal. +private struct WaterSurfaceUniforms { + var tint: SIMD4 + var roughness: Float + var waveStrength: Float + var padding0: Float = 0 + var padding1: Float = 0 +} + +/// Rendering implementation installed by `WaterRenderPlugin`. +/// +/// The extension follows the same mental model as an application-local extension: +/// +/// 1. Register its bundled shader library. +/// 2. Declare owned textures and their lifecycle. +/// 3. Declare argument-buffer members shared by Swift and Metal. +/// 4. Register compute and model-surface pipelines. +/// 5. Add graph passes with complete resource access declarations. +/// +/// Only `id` and `buildGraph` are required by `RenderExtension`. This example +/// implements every optional hook because it demonstrates the complete package. +public final class WaterSurfaceRenderExtension: RenderExtension, @unchecked Sendable { + /// Stable owner ID used for collision detection, diagnostics, and cleanup. + public let id = WaterRenderPluginContract.extensionID + + private let shaderBundle: Bundle + private let generatePassID = "com.untold.fixture.water.generate-pass" + private let surfacePassID = "com.untold.fixture.water.surface-pass" + + /// Creates the extension with the bundle containing its precompiled metallib. + /// + /// The plugin factory passes `.module`; accepting a bundle keeps the rendering + /// implementation testable and reusable from another bundle if necessary. + public init(shaderBundle: Bundle) { + self.shaderBundle = shaderBundle + } + + /// Registers the package metallib containing the compute and fragment functions. + public func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerLibrary( + WaterRenderPluginContract.shaderLibraryID, + bundle: shaderBundle, + resource: "WaterRenderPlugin" + ) + } + + /// Declares textures owned exclusively by this extension. + /// + /// Every pass using these textures must repeat the corresponding read or write + /// access in `buildGraph`; registration alone does not grant pass access. + public func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: WaterRenderPluginContract.colorTextureID, + label: "Fixture Water Color", + size: .viewportScale(1), + pixelFormat: .rgba16Float, + usage: [.shaderRead, .shaderWrite] + ) + ) + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: WaterRenderPluginContract.normalTextureID, + label: "Fixture Water Normal", + size: .viewportScale(1), + pixelFormat: .rgba16Float, + usage: [.shaderRead, .shaderWrite] + ) + ) + } + + /// Declares the local texture and buffer IDs encoded for each surface draw. + /// + /// These IDs must match the members read from + /// `UntoldModelSurfaceExtensionArguments` in the Metal fragment function. + public func registerArgumentBuffers(_ registry: RenderExtensionArgumentBufferRegistry) { + registry.registerArgumentBuffer( + RenderExtensionArgumentBufferDescriptor( + id: WaterRenderPluginContract.argumentLayoutID, + textures: [ + RenderExtensionArgumentTexture( + id: RenderExtensionModelSurfaceArgument.texture0 + ), + RenderExtensionArgumentTexture( + id: RenderExtensionModelSurfaceArgument.texture1 + ), + ], + buffers: [ + RenderExtensionArgumentBuffer( + id: RenderExtensionModelSurfaceArgument.buffer0 + ), + ] + ) + ) + } + + /// Creates the compute pipeline that generates the extension textures. + public func registerComputePipelines(_ registry: ComputePipelineRegistry) { + registry.registerComputePipeline( + RenderExtensionComputePipelineDescriptor( + id: WaterRenderPluginContract.computePipelineID, + function: "waterFixtureTextureKernel", + shaderLibrary: .registered(WaterRenderPluginContract.shaderLibraryID), + name: "Fixture Water Texture" + ) + ) + } + + /// Creates the model-surface pipeline using the engine vertex stage and the + /// package's argument-buffer fragment function. + public func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerModelSurfacePipeline( + WaterRenderPluginContract.surfacePipelineID, + fragmentShader: "waterFixtureSurfaceFragment", + fragmentShaderLibrary: .registered(WaterRenderPluginContract.shaderLibraryID), + name: "Fixture Water Surface", + validation: .warn(argumentLayoutID: WaterRenderPluginContract.argumentLayoutID) + ) + } + + /// Adds generation and drawing work to the render graph. + /// + /// `buildGraph` declares work only. The closures execute later while encoding + /// a frame. Both passes use the same stable stage, and registration order plus + /// the declared write/read hazard orders generation before drawing. + public func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: generatePassID, + stage: .beforePostProcess, + resources: [ + .texture(WaterRenderPluginContract.colorTextureID, access: .write), + .texture(WaterRenderPluginContract.normalTextureID, access: .write), + ] + ) { context in + // Resource and pipeline lookups are scoped to declarations made by + // this extension and this pass. A missing value safely skips encoding. + guard let colorTexture = context.resources.texture( + WaterRenderPluginContract.colorTextureID + ), let normalTexture = context.resources.texture( + WaterRenderPluginContract.normalTextureID + ), let pipeline = context.computePipelines.pipeline( + WaterRenderPluginContract.computePipelineID + )?.pipelineState, let encoder = context.commandBuffer.makeComputeCommandEncoder() else { + return + } + + encoder.setComputePipelineState(pipeline) + encoder.setTexture(colorTexture, index: 0) + encoder.setTexture(normalTexture, index: 1) + let width = max(1, pipeline.threadExecutionWidth) + let height = max(1, pipeline.maxTotalThreadsPerThreadgroup / width) + encoder.dispatchThreads( + MTLSize(width: colorTexture.width, height: colorTexture.height, depth: 1), + threadsPerThreadgroup: MTLSize(width: width, height: height, depth: 1) + ) + encoder.endEncoding() + } + + builder.addPass( + id: surfacePassID, + stage: .beforePostProcess, + resources: [ + .texture(WaterRenderPluginContract.colorTextureID, access: .read), + .texture(WaterRenderPluginContract.normalTextureID, access: .read), + ] + ) { context in + // The helper supplies engine model bindings and draws only entities + // carrying WaterSurfaceComponent. + context.drawModelSurfaceEntities( + pipeline: WaterRenderPluginContract.surfacePipelineID, + matching: [WaterSurfaceComponent.self], + label: "Fixture Water Surface", + argumentLayoutID: WaterRenderPluginContract.argumentLayoutID, + bindArguments: { arguments, entityID, resources in + // This closure runs once per matching entity, allowing each + // model to encode independent component values. + guard let component = getEntityComponent( + entityId: entityID, + componentType: WaterSurfaceComponent.self + ) else { + return + } + + arguments.setTexture( + resources.texture(WaterRenderPluginContract.colorTextureID), + id: RenderExtensionModelSurfaceArgument.texture0 + ) + arguments.setTexture( + resources.texture(WaterRenderPluginContract.normalTextureID), + id: RenderExtensionModelSurfaceArgument.texture1 + ) + var uniforms = WaterSurfaceUniforms( + tint: component.tint, + roughness: component.roughness, + waveStrength: component.waveStrength + ) + arguments.setBytes( + &uniforms, + id: RenderExtensionModelSurfaceArgument.buffer0 + ) + } + ) + } + } +} diff --git a/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift b/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift new file mode 100644 index 00000000..2acc0030 --- /dev/null +++ b/Examples/RenderingExtensions/SwiftPackagePlugin/Tests/WaterRenderPluginTests/WaterRenderPluginTests.swift @@ -0,0 +1,32 @@ +import Metal +import UntoldEngine +import WaterRenderPlugin +import XCTest + +final class WaterRenderPluginTests: XCTestCase { + func testPluginManifestAndExtensionNamespaceAreValid() { + let plugin = WaterRenderPlugin() + + XCTAssertEqual(plugin.manifest.id, WaterRenderPluginContract.pluginID) + XCTAssertEqual(plugin.manifest.requiredAPIVersion, .current) + XCTAssertTrue(RenderExtensionPluginValidator.validate(plugin).isValid) + XCTAssertEqual( + plugin.makeRenderExtensions().map(\.id), + [WaterRenderPluginContract.extensionID] + ) + } + + func testBundledMetallibContainsEveryDeclaredFunction() throws { + let url = try XCTUnwrap(WaterRenderPlugin.bundledMetallibURL) + let device = try XCTUnwrap(MTLCreateSystemDefaultDevice()) + let library = try device.makeLibrary(URL: url) + + XCTAssertNotNil(library.makeFunction(name: "waterFixtureTextureKernel")) + XCTAssertNotNil(library.makeFunction(name: "waterFixtureSurfaceFragment")) + } + + func testPublicRegistrationEntryPointHasPluginInstallationSignature() { + let entryPoint: () -> RenderExtensionPluginInstallationResult = registerWaterRenderPlugin + _ = entryPoint + } +} diff --git a/Package.swift b/Package.swift index 3fc1cedd..d17acb95 100644 --- a/Package.swift +++ b/Package.swift @@ -51,6 +51,11 @@ let package = Package( targets: ["UntoldEngine"] ), + .library( + name: "UntoldEngineShaderSupport", + targets: ["UntoldEngineShaderSupport"] + ), + .library(name: "UntoldEngineXR", targets: ["UntoldEngineXR"]), .library(name: "UntoldEngineAR", targets: ["UntoldEngineAR"]), @@ -92,6 +97,11 @@ let package = Package( targets: ["ExporterPipelineDemo"] ), + .executable( + name: "lightingdemo", + targets: ["LightingDemo"] + ), + .executable( name: "sandbox", targets: ["Sandbox"] @@ -113,6 +123,14 @@ let package = Package( .headerSearchPath("."), ] ), + .target( + name: "UntoldEngineShaderSupport", + path: "Sources/UntoldEngineShaderSupport", + publicHeadersPath: "include", + cSettings: [ + .headerSearchPath("include"), + ] + ), .target( name: "UntoldEngine", dependencies: ["CShaderTypes"], @@ -177,6 +195,12 @@ let package = Package( .linkedFramework("ARKit", .when(platforms: [.iOS])), ] ), + .target( + name: "DemoUtils", + dependencies: ["UntoldEngine"], + path: "Sources/Demos/DemoUtils", + swiftSettings: [.swiftLanguageMode(.v6)] + ), // These executables are macOS-only .executableTarget( name: "ShowcaseDemo", @@ -191,7 +215,7 @@ let package = Package( ), .executableTarget( name: "StarterDemo", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "DemoUtils"], path: "Sources/Demos/StarterDemo", exclude: ["README.md"], swiftSettings: [.swiftLanguageMode(.v6)], @@ -203,7 +227,7 @@ let package = Package( ), .executableTarget( name: "LargeSceneStreamingDemo", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "DemoUtils"], path: "Sources/Demos/LargeSceneStreamingDemo", exclude: ["README.md"], swiftSettings: [.swiftLanguageMode(.v6)], @@ -215,7 +239,7 @@ let package = Package( ), .executableTarget( name: "InteractionGameplayDemo", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "DemoUtils"], path: "Sources/Demos/InteractionGameplayDemo", exclude: ["README.md"], swiftSettings: [.swiftLanguageMode(.v6)], @@ -227,7 +251,7 @@ let package = Package( ), .executableTarget( name: "RenderingQualityDemo", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "DemoUtils"], path: "Sources/Demos/RenderingQualityDemo", exclude: ["README.md"], swiftSettings: [.swiftLanguageMode(.v6)], @@ -239,7 +263,7 @@ let package = Package( ), .executableTarget( name: "ExporterPipelineDemo", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "DemoUtils"], path: "Sources/Demos/ExporterPipelineDemo", exclude: ["README.md"], swiftSettings: [.swiftLanguageMode(.v6)], @@ -249,6 +273,17 @@ let package = Package( .linkedFramework("AppKit", .when(platforms: [.macOS])), ] ), + .executableTarget( + name: "LightingDemo", + dependencies: ["UntoldEngine", "DemoUtils"], + path: "Sources/Demos/LightingDemo", + swiftSettings: [.swiftLanguageMode(.v6)], + linkerSettings: [ + .linkedFramework("Metal"), + .linkedFramework("QuartzCore", .when(platforms: [.macOS, .iOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ] + ), .executableTarget( name: "Sandbox", dependencies: ["UntoldEngine"], @@ -263,7 +298,7 @@ let package = Package( // Test target for unit tests .testTarget( name: "UntoldEngineTests", - dependencies: ["UntoldEngine"], + dependencies: ["UntoldEngine", "UntoldEngineShaderSupport"], path: "Tests/UntoldEngineTests", swiftSettings: [.swiftLanguageMode(.v6)] ), diff --git a/README.md b/README.md index 9693c094..8db45171 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ After that, run the focused demos based on what 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. | +| Lighting Demo | `swift run lightingdemo` | All four light types — directional, point, spot, and area — with live intensity and cone-angle controls. | | 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. | @@ -167,6 +168,8 @@ Untold Engine is well-suited for: - [Transform System](docs/API/UsingTransformSystem.md) - [Camera System](docs/API/UsingCameraSystem.md) - [Rendering System](docs/API/UsingRenderingSystem.md) +- [Rendering Extensions](docs/API/UsingRenderingExtensions.md) +- [Rendering Extension Examples](Examples/RenderingExtensions/README.md) - [Lighting System](docs/API/UsingLightingSystem.md) - [Light Portals](docs/API/UsingLightPortals.md) - [Materials](docs/API/UsingMaterials.md) @@ -202,6 +205,7 @@ Untold Engine is well-suited for: - [Texture Streaming System](docs/Architecture/textureStreamingSystem.md) - [Out of Core](docs/Architecture/outOfCore.md) - [Asset Remote Streaming](docs/Architecture/asset_remote_streaming.md) +- [Rendering Extensions](docs/Architecture/RenderingExtensions.md) --- diff --git a/Sources/Demos/DemoUtils/DemoUtils.swift b/Sources/Demos/DemoUtils/DemoUtils.swift new file mode 100644 index 00000000..ff343adc --- /dev/null +++ b/Sources/Demos/DemoUtils/DemoUtils.swift @@ -0,0 +1,82 @@ +#if os(macOS) + import Foundation + import simd + import UntoldEngine + + // MARK: - Resources + + /// #filePath anchors to this file at compile time, so the repo root is always + /// 4 levels up: DemoUtils/ → Demos/ → Sources/ → repo root. + public func demoResourcesURL() -> URL { + let here = URL(fileURLWithPath: #filePath) + return here + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Tests/UntoldEngineRenderTests/Resources") + } + + // MARK: - Engine Configuration + + public func configureDemoEngine( + assetBasePath: URL? = nil, + registerKeyboard: Bool = false, + registerMouse: Bool = true + ) { + gameMode = true + setSceneReady(false) + if let basePath = assetBasePath { + setEngine(.assetBasePath(basePath)) + } + setRendering(.postProcessing(.enabled)) + setRendering(.antiAliasing(.fxaa)) + setRendering(.environment(.ibl(true))) + setRendering(.environment(.visible(false))) + if registerKeyboard { + InputSystem.shared.registerKeyboardEvents() + } + if registerMouse { + InputSystem.shared.registerMouseEvents() + } + } + + // MARK: - Camera + + @discardableResult + public func makeDemoCamera( + name: String = "Main Camera", + eye: simd_float3, + target: simd_float3 = .zero, + orbitOffset: Float? = nil + ) -> EntityID { + let camera = createEntity() + setEntityName(entityId: camera, name: name) + createGameCamera(entityId: camera) + cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0)) + if let offset = orbitOffset { + setOrbitOffset(entityId: camera, uTargetOffset: offset) + } + setCamera(.active(camera)) + return camera + } + + // MARK: - Lighting + + @discardableResult + public func makeDemoSunLight( + name: String = "Sun", + pitch: Float = -50.0, + color: simd_float3 = simd_float3(1.0, 0.94, 0.86), + intensity: Float = 1.5 + ) -> EntityID { + let sun = createEntity() + setEntityName(entityId: sun, name: name) + createDirLight(entityId: sun) + rotateTo(entityId: sun, angle: pitch, axis: simd_float3(1, 0, 0)) + setLight(entityId: sun, .color(color)) + setLight(entityId: sun, .intensity(intensity)) + setLight(entityId: sun, .directional(.active)) + return sun + } +#endif diff --git a/Sources/Demos/ExporterPipelineDemo/GameScene.swift b/Sources/Demos/ExporterPipelineDemo/GameScene.swift index 5bdcafa1..a3c5d072 100644 --- a/Sources/Demos/ExporterPipelineDemo/GameScene.swift +++ b/Sources/Demos/ExporterPipelineDemo/GameScene.swift @@ -4,6 +4,7 @@ // #if os(macOS) + import DemoUtils import Foundation import simd import UntoldEngine @@ -82,9 +83,9 @@ private var wasRightMousePressed = false init() { - configureEngine() - createCamera() - createLight() + configureDemoEngine(assetBasePath: demoResourcesURL()) + makeDemoCamera(name: "Pipeline Camera", eye: Constants.cameraEye, target: Constants.cameraTarget, orbitOffset: Constants.orbitOffset) + makeDemoSunLight(name: "Pipeline Key Light", pitch: -50.0, intensity: 1.5) loadAsset(.redplayer) } @@ -171,54 +172,6 @@ } } - 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( @@ -232,14 +185,14 @@ } private func assetURL(for option: ExportedAssetOption) -> URL { - Self.resourcesURL() + demoResourcesURL() .appendingPathComponent("Models") .appendingPathComponent(option.rawValue) .appendingPathComponent("\(option.rawValue).untold") } private func validationURL(for option: ExportedAssetOption) -> URL { - Self.resourcesURL() + demoResourcesURL() .appendingPathComponent("Models") .appendingPathComponent(option.rawValue) .appendingPathComponent("\(option.rawValue).validation.json") diff --git a/Sources/Demos/InteractionGameplayDemo/GameScene.swift b/Sources/Demos/InteractionGameplayDemo/GameScene.swift index acb59970..95aabf42 100644 --- a/Sources/Demos/InteractionGameplayDemo/GameScene.swift +++ b/Sources/Demos/InteractionGameplayDemo/GameScene.swift @@ -4,6 +4,7 @@ // #if os(macOS) + import DemoUtils import Foundation import simd import UntoldEngine @@ -27,9 +28,9 @@ private var ballAttached = false init() { - configureEngine() - createCamera() - createLight() + configureDemoEngine(assetBasePath: demoResourcesURL(), registerKeyboard: true, registerMouse: false) + makeDemoCamera(name: "Gameplay Camera", eye: Constants.cameraEye, target: Constants.cameraTarget) + makeDemoSunLight(name: "Sun", pitch: -55.0, color: simd_float3(1.0, 0.94, 0.86), intensity: 1.6) loadScene() } @@ -73,53 +74,6 @@ 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 diff --git a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift index 454a5c62..1fff33ee 100644 --- a/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift +++ b/Sources/Demos/LargeSceneStreamingDemo/GameScene.swift @@ -4,6 +4,7 @@ // #if os(macOS) + import DemoUtils import Foundation import simd import SwiftUI @@ -195,21 +196,11 @@ } private func createCamera() { - let camera = createEntity() - setEntityName(entityId: camera, name: "Streaming Camera") - createGameCamera(entityId: camera) - setCamera(.active(camera)) - placeCamera(eye: RemoteScenePreset.dungeon.cameraEye) + makeDemoCamera(name: "Streaming Camera", eye: RemoteScenePreset.dungeon.cameraEye, orbitOffset: Constants.orbitTargetOffset) } 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)) + makeDemoSunLight(name: "Key Light", pitch: -45.0, intensity: 1.4) } private func placeCamera(eye: simd_float3) { diff --git a/Sources/Demos/LightingDemo/AppDelegate.swift b/Sources/Demos/LightingDemo/AppDelegate.swift new file mode 100644 index 00000000..8fefdea0 --- /dev/null +++ b/Sources/Demos/LightingDemo/AppDelegate.swift @@ -0,0 +1,246 @@ +#if os(macOS) + import AppKit + import Observation + import SwiftUI + import UntoldEngine + + @MainActor @Observable + final class LightingState { + var dirEnabled: Bool = true + var dirIntensity: Double = 1.2 + + var pointEnabled: Bool = true + var pointIntensity: Double = 3.0 + + var spotEnabled: Bool = true + var spotIntensity: Double = 4.0 + var spotConeAngle: Double = 20.0 + + var areaEnabled: Bool = true + var areaIntensity: Double = 2.0 + } + + struct LightingActions { + let onDirChanged: () -> Void + let onPointChanged: () -> Void + let onSpotChanged: () -> Void + let onAreaChanged: () -> Void + } + + @MainActor + final class AppDelegate: NSObject, NSApplicationDelegate { + private enum Constants { + static let windowSize = NSSize(width: 1280, height: 760) + static let minimumWindowSize = NSSize(width: 800, height: 600) + } + + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var gameScene: GameScene! + private let state = LightingState() + + 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 – Lighting 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: LightingDemoView(renderer: renderer, state: state, actions: makeActions()) + ) + window.makeKeyAndOrderFront(nil) + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + private func makeActions() -> LightingActions { + LightingActions( + onDirChanged: { [weak self] in + guard let self else { return } + gameScene.setDirLight(enabled: state.dirEnabled, intensity: Float(state.dirIntensity)) + }, + onPointChanged: { [weak self] in + guard let self else { return } + gameScene.setPointLight(enabled: state.pointEnabled, intensity: Float(state.pointIntensity)) + }, + onSpotChanged: { [weak self] in + guard let self else { return } + gameScene.setSpotLight( + enabled: state.spotEnabled, + intensity: Float(state.spotIntensity), + coneAngle: Float(state.spotConeAngle) + ) + }, + onAreaChanged: { [weak self] in + guard let self else { return } + gameScene.setAreaLight(enabled: state.areaEnabled, intensity: Float(state.areaIntensity)) + } + ) + } + } + + // MARK: - HUD + + struct LightingDemoView: View { + let renderer: UntoldRenderer + @Bindable var state: LightingState + let actions: LightingActions + + var body: some View { + ZStack(alignment: .topLeading) { + SceneView(renderer: renderer) + + ScrollView { + controlsPanel + } + .frame(width: 300) + .frame(maxHeight: 680) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(16) + } + } + + private var controlsPanel: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Lighting").font(.headline) + Text("Right-drag to orbit") + .font(.caption) + .foregroundStyle(.secondary) + + Divider() + + lightSection( + title: "DIRECTIONAL", + code: "createDirLight(entityId:)", + enabled: $state.dirEnabled, + intensity: $state.dirIntensity, + intensityRange: 0 ... 3, + onChange: actions.onDirChanged + ) + + Divider() + + lightSection( + title: "POINT", + code: "createPointLight(entityId:)", + enabled: $state.pointEnabled, + intensity: $state.pointIntensity, + intensityRange: 0 ... 8, + onChange: actions.onPointChanged + ) + + Divider() + + spotSection + + Divider() + + lightSection( + title: "AREA", + code: "createAreaLight(entityId:)", + enabled: $state.areaEnabled, + intensity: $state.areaIntensity, + intensityRange: 0 ... 6, + onChange: actions.onAreaChanged + ) + } + .padding(12) + } + + private var spotSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionHeader(title: "SPOT", code: "createSpotLight(entityId:)") + + Toggle("Enabled", isOn: $state.spotEnabled) + .onChange(of: state.spotEnabled) { _, _ in actions.onSpotChanged() } + + slider("Intensity", $state.spotIntensity, 0 ... 10, state.spotEnabled, actions.onSpotChanged) + slider("Cone Angle", $state.spotConeAngle, 5 ... 60, state.spotEnabled, actions.onSpotChanged) + } + } + + private func lightSection( + title: String, + code: String, + enabled: Binding, + intensity: Binding, + intensityRange: ClosedRange, + onChange: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionHeader(title: title, code: code) + + Toggle("Enabled", isOn: enabled) + .onChange(of: enabled.wrappedValue) { _, _ in onChange() } + + slider("Intensity", intensity, intensityRange, enabled.wrappedValue, onChange) + } + } + + private func sectionHeader(title: String, code: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(code) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + } + } + + private func slider( + _ label: String, + _ value: Binding, + _ range: ClosedRange, + _ enabled: Bool, + _ onChange: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + Spacer() + Text(value.wrappedValue, format: .number.precision(.fractionLength(1))) + .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/LightingDemo/GameScene.swift b/Sources/Demos/LightingDemo/GameScene.swift new file mode 100644 index 00000000..34b93a15 --- /dev/null +++ b/Sources/Demos/LightingDemo/GameScene.swift @@ -0,0 +1,171 @@ +#if os(macOS) + import DemoUtils + import Foundation + import simd + import SwiftUI + import UntoldEngine + + final class GameScene: @unchecked Sendable { + private enum Constants { + static let cameraEye = simd_float3(0.0, 4.5, 11.0) + static let cameraTarget = simd_float3(0.0, 1.0, 0.0) + static let orbitOffset: Float = 10.0 + } + + private var dirLight: EntityID? + private var pointLight: EntityID? + private var spotLight: EntityID? + private var areaLight: EntityID? + private var wasRightMousePressed = false + + init() { + configureDemoEngine(registerMouse: true) + makeDemoCamera( + name: "Lighting Camera", + eye: Constants.cameraEye, + target: Constants.cameraTarget, + orbitOffset: Constants.orbitOffset + ) + buildScene() + buildLights() + setSceneReady(true) + } + + func update(deltaTime _: Float) { + if gameMode == false { return } + } + + func handleInput() { + if gameMode == 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 + } + + // MARK: - Scene + + private func buildScene() { + 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)) + + 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)) + + spawnSphere( + name: "Sphere Left", + assetName: "sphere_left", + position: simd_float3(-3.0, 0.5, -1.0), + color: Color(red: 0.85, green: 0.22, blue: 0.16) + ) + spawnSphere( + name: "Sphere Center", + assetName: "sphere_center", + position: simd_float3(0.0, 0.5, 0.5), + color: Color(red: 0.92, green: 0.82, blue: 0.18) + ) + spawnSphere( + name: "Sphere Right", + assetName: "sphere_right", + position: simd_float3(3.0, 0.5, -1.0), + color: Color(red: 0.18, green: 0.52, blue: 0.90) + ) + } + + private func spawnSphere(name: String, assetName: String, position: simd_float3, color: Color) { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createSphere(extent: 0.5), assetName: assetName) + translateTo(entityId: entity, position: position) + updateMaterialColor(entityId: entity, color: color) + } + + // MARK: - Lights + + private func buildLights() { + // Directional — warm sun, active by default as the scene's primary shadow caster. + 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)) + dirLight = dir + + // Point — warm orange, right side of scene. + 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))) + pointLight = pt + + // Spot — cool blue, angled from upper-left. + 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))) + spotLight = sp + + // Area — soft purple, overhead panel. + 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))) + areaLight = ar + } + + // MARK: - Light Control API + + func setDirLight(enabled: Bool, intensity: Float) { + guard let dirLight else { return } + setLight(entityId: dirLight, .intensity(enabled ? intensity : 0)) + } + + func setPointLight(enabled: Bool, intensity: Float) { + guard let pointLight else { return } + setLight(entityId: pointLight, .intensity(enabled ? intensity : 0)) + } + + 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))) + } + } + + func setAreaLight(enabled: Bool, intensity: Float) { + guard let areaLight else { return } + setLight(entityId: areaLight, .intensity(enabled ? intensity : 0)) + } + } +#endif diff --git a/Sources/Demos/LightingDemo/main.swift b/Sources/Demos/LightingDemo/main.swift new file mode 100644 index 00000000..ba94bcb1 --- /dev/null +++ b/Sources/Demos/LightingDemo/main.swift @@ -0,0 +1,8 @@ +#if os(macOS) + import AppKit + + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +#endif diff --git a/Sources/Demos/RenderingQualityDemo/GameScene.swift b/Sources/Demos/RenderingQualityDemo/GameScene.swift index 3b61a807..cd335d15 100644 --- a/Sources/Demos/RenderingQualityDemo/GameScene.swift +++ b/Sources/Demos/RenderingQualityDemo/GameScene.swift @@ -4,6 +4,7 @@ // #if os(macOS) + import DemoUtils import Foundation import simd import UntoldEngine @@ -164,47 +165,18 @@ private func configureEngine() { gameMode = true setSceneReady(false) - setEngine(.assetBasePath(Self.resourcesURL())) + setEngine(.assetBasePath(demoResourcesURL())) 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)) + makeDemoCamera(name: "Quality Camera", eye: Constants.cameraEye, target: Constants.cameraTarget, orbitOffset: Constants.orbitOffset) } 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)) + makeDemoSunLight(name: "Key Light", pitch: -50.0, intensity: 1.55) let fill = createEntity() setEntityName(entityId: fill, name: "Fill Light") diff --git a/Sources/Demos/StarterDemo/GameScene.swift b/Sources/Demos/StarterDemo/GameScene.swift index d53f8ba6..4904ace3 100644 --- a/Sources/Demos/StarterDemo/GameScene.swift +++ b/Sources/Demos/StarterDemo/GameScene.swift @@ -4,6 +4,7 @@ // #if os(macOS) + import DemoUtils import Foundation import simd import SwiftUI @@ -21,9 +22,9 @@ private var wasRightMousePressed = false init() { - configureEngine() - createCamera() - createLight() + configureDemoEngine(registerKeyboard: true) + makeDemoCamera(name: "Main Camera", eye: Constants.cameraStart, target: Constants.worldOrigin, orbitOffset: Constants.orbitTargetOffset) + makeDemoSunLight(name: "Key Light", pitch: -45.0, color: simd_float3(1.0, 0.92, 0.82), intensity: 1.4) createStarterObject() setSceneReady(false) } @@ -72,42 +73,6 @@ 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") diff --git a/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift b/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift index 1bed2d09..918404cd 100644 --- a/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift +++ b/Sources/UntoldEngine/BuildSystem/BuildTemplates.swift @@ -31,6 +31,7 @@ import Foundation let package = Package( name: "{{PROJECT_NAME}}", + defaultLocalization: "en", platforms: [ .macOS(.v{{MACOS_VERSION}}) ], @@ -48,7 +49,8 @@ import Foundation .executableTarget( name: "{{PROJECT_NAME}}", dependencies: [ - .product(name: "UntoldEngine", package: "UntoldEngine") + .product(name: "UntoldEngine", package: "UntoldEngine"), + .product(name: "UntoldEngineShaderSupport", package: "UntoldEngine") ], resources: [ .process("GameData") diff --git a/Sources/UntoldEngine/BuildSystem/XcodeGenProjectSpec.swift b/Sources/UntoldEngine/BuildSystem/XcodeGenProjectSpec.swift index 595157f1..88ffac90 100644 --- a/Sources/UntoldEngine/BuildSystem/XcodeGenProjectSpec.swift +++ b/Sources/UntoldEngine/BuildSystem/XcodeGenProjectSpec.swift @@ -29,6 +29,8 @@ import Foundation MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" INFOPLIST_FILE: Sources/\(settings.projectName)/Info.plist + UNTOLD_ENGINE_PACKAGE_ROOT: "$(BUILD_DIR)/../../SourcePackages/checkouts/UntoldEngine" + MTL_HEADER_SEARCH_PATHS: "$(inherited) $(UNTOLD_ENGINE_PACKAGE_ROOT)/Sources/UntoldEngineShaderSupport/include" """ // Add iOS and visionOS code signing settings @@ -81,8 +83,10 @@ import Foundation .joined(separator: "\n") } - // Build sources section - visionOS doesn't use Base.lproj - var sourcesSection = """ + // Build sources section. Storyboards under Base.lproj are compiled + // from the Sources tree; do not add Base.lproj as a raw folder + // resource or the app bundle will contain an uncompiled storyboard. + let sourcesSection = """ sources: - path: Sources excludes: @@ -92,23 +96,6 @@ import Foundation buildPhase: resources """ - // Only add Base.lproj for platforms that use storyboards (macOS, iOS) - if case .macOS = settings.target { - sourcesSection += """ - - - path: Sources/\(settings.projectName)/Base.lproj - type: folder - buildPhase: resources - """ - } else if case .iOS = settings.target { - sourcesSection += """ - - - path: Sources/\(settings.projectName)/Base.lproj - type: folder - buildPhase: resources - """ - } - // Packages section let packagesSection: String if case .visionOS = settings.target { @@ -155,6 +142,8 @@ import Foundation dependencies: - package: UntoldEngine product: UntoldEngine + - package: UntoldEngine + product: UntoldEngineShaderSupport """ } @@ -202,7 +191,9 @@ import Foundation SWIFT_VERSION: 5.0 MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" - INFOPLIST_FILE: \(settings.projectName) macOS/Info.plist\(teamIDLine) + INFOPLIST_FILE: \(settings.projectName) macOS/Info.plist + UNTOLD_ENGINE_PACKAGE_ROOT: "$(BUILD_DIR)/../../SourcePackages/checkouts/UntoldEngine" + MTL_HEADER_SEARCH_PATHS: "$(inherited) $(UNTOLD_ENGINE_PACKAGE_ROOT)/Sources/UntoldEngineShaderSupport/include"\(teamIDLine) configs: Debug: SWIFT_OPTIMIZATION_LEVEL: -Onone @@ -225,6 +216,8 @@ import Foundation dependencies: - package: UntoldEngine product: UntoldEngine + - package: UntoldEngine + product: UntoldEngineShaderSupport settings: base: PRODUCT_BUNDLE_IDENTIFIER: \(settings.bundleIdentifier) @@ -232,6 +225,8 @@ import Foundation MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" INFOPLIST_FILE: \(settings.projectName) iOS/Info.plist + UNTOLD_ENGINE_PACKAGE_ROOT: "$(BUILD_DIR)/../../SourcePackages/checkouts/UntoldEngine" + MTL_HEADER_SEARCH_PATHS: "$(inherited) $(UNTOLD_ENGINE_PACKAGE_ROOT)/Sources/UntoldEngineShaderSupport/include" CODE_SIGN_STYLE: Automatic\(teamIDLine) configs: Debug: diff --git a/Sources/UntoldEngine/ECS/Scenes.swift b/Sources/UntoldEngine/ECS/Scenes.swift index df6d2ce9..9ed27577 100644 --- a/Sources/UntoldEngine/ECS/Scenes.swift +++ b/Sources/UntoldEngine/ECS/Scenes.swift @@ -268,6 +268,11 @@ public func queryEntitiesWithComponentIds(_ componentTypes: [Int], in scene: Sce return candidates.filter { scene.exists($0) } } +public func queryEntities(with componentTypes: [any Component.Type]) -> [EntityID] { + let componentIds = componentTypes.map { getComponentId(for: $0) } + return queryEntitiesWithComponentIds(componentIds, in: scene) +} + public func hasComponent(entityId: EntityID, componentType: (some Any).Type) -> Bool { let entityIndex: EntityIndex = getEntityIndex(entityId) guard entityIndex < scene.entities.count else { return false } @@ -279,6 +284,26 @@ public func hasComponent(entityId: EntityID, componentType: (some Any).Type) -> return entityMask.test(componentId) } +public func getEntityComponent( + entityId: EntityID, + componentType: T.Type = T.self +) -> T? { + guard scene.exists(entityId) else { + return nil + } + return scene.get(component: componentType, for: entityId) +} + +public func removeEntityComponent( + entityId: EntityID, + componentType: (some Component).Type +) { + guard scene.exists(entityId), hasComponent(entityId: entityId, componentType: componentType) else { + return + } + scene.remove(component: componentType, from: entityId) +} + func getAllEntityComponentsTypes(entityId: EntityID) -> [Any.Type] { let entityIndex: EntityIndex = getEntityIndex(entityId) guard entityIndex < scene.entities.count else { return [] } diff --git a/Sources/UntoldEngine/Renderer/Pipelines/PipelineManager.swift b/Sources/UntoldEngine/Renderer/Pipelines/PipelineManager.swift index db6fde10..18d68402 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/PipelineManager.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/PipelineManager.swift @@ -15,7 +15,13 @@ public final class PipelineManager: @unchecked Sendable { public static let shared: PipelineManager = .init() private let lock = NSLock() + private let registrationLock = NSRecursiveLock() private var _renderPipelinesByType: [RenderPipelineType: RenderPipeline] = [:] + private var renderPipelineOwners: [RenderPipelineType: String] = [:] + private var currentRegistrationOwnerID: String? + private var currentConflictCollector: RenderExtensionConflictCollector? + private var currentErrorCollector: RenderExtensionPipelineErrorCollector? + private var currentRegistrationIDs: Set? public var renderPipelinesByType: [RenderPipelineType: RenderPipeline] { lock.lock() @@ -25,16 +31,126 @@ public final class PipelineManager: @unchecked Sendable { } func initRenderPipelines(_ pipelines: [(RenderPipelineType, RenderPipelineInitBlock)]) { + registrationLock.lock() + defer { registrationLock.unlock() } + lock.lock() for (type, initBlock) in pipelines { _renderPipelinesByType[type] = initBlock() + renderPipelineOwners.removeValue(forKey: type) } lock.unlock() } public func update(rendererPipeLine: RenderPipeline, forType type: RenderPipelineType) { + registrationLock.lock() + defer { registrationLock.unlock() } + lock.lock() + if currentRegistrationOwnerID != nil, + currentRegistrationIDs?.insert(type).inserted == false + { + currentErrorCollector?.record( + .duplicatePipelineID(kind: .renderPipeline, pipelineID: type.rawValue) + ) + lock.unlock() + return + } + if let ownerID = currentRegistrationOwnerID, + _renderPipelinesByType[type] != nil, + renderPipelineOwners[type] != ownerID + { + currentConflictCollector?.record( + RenderExtensionArtifactConflict( + kind: .renderPipeline, + artifactID: type.rawValue, + requestedOwnerID: ownerID, + existingOwnerID: renderPipelineOwners[type] + ) + ) + lock.unlock() + return + } _renderPipelinesByType[type] = rendererPipeLine + if let currentRegistrationOwnerID { + renderPipelineOwners[type] = currentRegistrationOwnerID + } else { + renderPipelineOwners.removeValue(forKey: type) + } + lock.unlock() + } + + @discardableResult + func registerPipelines( + ownerID: String, + _ registerBlock: (RenderPipelineRegistry) -> Void + ) -> RenderExtensionPipelineRegistrationReport { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let previousOwnerID = currentRegistrationOwnerID + let previousCollector = currentConflictCollector + let previousErrorCollector = currentErrorCollector + let previousRegistrationIDs = currentRegistrationIDs + let conflictCollector = RenderExtensionConflictCollector() + let errorCollector = RenderExtensionPipelineErrorCollector() + currentRegistrationOwnerID = ownerID + currentConflictCollector = conflictCollector + currentErrorCollector = errorCollector + currentRegistrationIDs = [] + lock.unlock() + + registerBlock(RenderPipelineRegistry()) + + lock.lock() + currentRegistrationOwnerID = previousOwnerID + currentConflictCollector = previousCollector + currentErrorCollector = previousErrorCollector + currentRegistrationIDs = previousRegistrationIDs + lock.unlock() + return RenderExtensionPipelineRegistrationReport( + conflicts: conflictCollector.conflicts, + errors: errorCollector.errors + ) + } + + func recordRegistrationError(_ error: RenderExtensionPipelineError) { + lock.lock() + let collector = currentErrorCollector + lock.unlock() + if let collector { + collector.record(error) + } else { + Logger.logWarning(message: "[RenderExtension] \(error.description)") + } + } + + func removePipelines(ownerID: String) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let ownedTypes = renderPipelineOwners.compactMap { type, owner in + owner == ownerID ? type : nil + } + for type in ownedTypes { + _renderPipelinesByType.removeValue(forKey: type) + renderPipelineOwners.removeValue(forKey: type) + } + lock.unlock() + } + + func removeAllExtensionPipelines() { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let ownedTypes = Array(renderPipelineOwners.keys) + for type in ownedTypes { + _renderPipelinesByType.removeValue(forKey: type) + } + renderPipelineOwners.removeAll() lock.unlock() } } diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift index 1b2dab49..e8e32272 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipeLines.swift @@ -41,6 +41,8 @@ public enum PipelineBlendMode { public func CreatePipeline( vertexShader: String, fragmentShader: String?, + vertexShaderLibrary: RenderShaderLibraryReference = .engine, + fragmentShaderLibrary: RenderShaderLibraryReference = .engine, vertexDescriptor: MTLVertexDescriptor?, colorFormats: [MTLPixelFormat], depthFormat: MTLPixelFormat, @@ -48,25 +50,35 @@ public func CreatePipeline( depthEnabled: Bool = true, reverseZCompatible: Bool = true, blendMode: PipelineBlendMode = .none, - name: String + name: String, + reflectionHandler: ((MTLRenderPipelineReflection) -> Void)? = nil ) -> RenderPipeline? { let pipelineDescriptor = MTLRenderPipelineDescriptor() let depthStateDescriptor = MTLDepthStencilDescriptor() - guard renderInfo.library != nil else { - handleError(.metalLibraryNotFound) + guard let vertexLibrary = resolveRenderShaderLibrary( + vertexShaderLibrary, + usage: "vertex shader '\(vertexShader)'" + ) else { return nil } do { - guard let vertexFunction = renderInfo.library.makeFunction(name: vertexShader) else { + guard let vertexFunction = vertexLibrary.makeFunction(name: vertexShader) else { handleError(.shaderCreationFailed, vertexShader) return nil } pipelineDescriptor.vertexFunction = vertexFunction if let fragmentShader { - guard let fragmentFunction = renderInfo.library.makeFunction(name: fragmentShader) else { + guard let fragmentLibrary = resolveRenderShaderLibrary( + fragmentShaderLibrary, + usage: "fragment shader '\(fragmentShader)'" + ) else { + return nil + } + + guard let fragmentFunction = fragmentLibrary.makeFunction(name: fragmentShader) else { handleError(.shaderCreationFailed, fragmentShader) return nil } @@ -123,7 +135,20 @@ public func CreatePipeline( ) depthStateDescriptor.isDepthWriteEnabled = depthEnabled - let pipelineState = try renderInfo.device.makeRenderPipelineState(descriptor: pipelineDescriptor) + let pipelineState: MTLRenderPipelineState + if let reflectionHandler { + var reflection: MTLAutoreleasedRenderPipelineReflection? + pipelineState = try renderInfo.device.makeRenderPipelineState( + descriptor: pipelineDescriptor, + options: [.bindingInfo], + reflection: &reflection + ) + if let reflection { + reflectionHandler(reflection) + } + } else { + pipelineState = try renderInfo.device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } let depthState = renderInfo.device.makeDepthStencilState(descriptor: depthStateDescriptor) return RenderPipeline( @@ -141,18 +166,21 @@ public func CreatePipeline( public func CreateTilePipeline( tileShader: String, + tileShaderLibrary: RenderShaderLibraryReference = .engine, colorFormats: [MTLPixelFormat], name: String ) -> RenderPipeline? { let pipelineDescriptor = MTLTileRenderPipelineDescriptor() - guard renderInfo.library != nil else { - handleError(.metalLibraryNotFound) + guard let tileLibrary = resolveRenderShaderLibrary( + tileShaderLibrary, + usage: "tile shader '\(tileShader)'" + ) else { return nil } do { - guard let tileFunction = renderInfo.library.makeFunction(name: tileShader) else { + guard let tileFunction = tileLibrary.makeFunction(name: tileShader) else { handleError(.shaderCreationFailed, tileShader) return nil } diff --git a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift index 907a8471..920fc28e 100644 --- a/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift +++ b/Sources/UntoldEngine/Renderer/Pipelines/RenderPipelineType.swift @@ -9,7 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. public struct RenderPipelineType: Hashable, ExpressibleByStringLiteral, Sendable { - let rawValue: String + public let rawValue: String public init(_ rawValue: String) { self.rawValue = rawValue diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift new file mode 100644 index 00000000..c64f85c7 --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift @@ -0,0 +1,562 @@ +// +// RenderExtensionModelSurface.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CShaderTypes +import Metal +import simd + +public typealias RenderModelSurfaceEntityBinding = ( + _ encoder: MTLRenderCommandEncoder, + _ entityId: EntityID, + _ resources: RenderResourceAccess +) -> Void + +public typealias RenderModelSurfaceArgumentBinding = ( + _ arguments: RenderModelSurfaceArgumentEncoder, + _ entityId: EntityID, + _ resources: RenderResourceAccess +) -> Void + +@available(*, deprecated, message: "Use RenderExtensionModelSurfaceArgument with drawModelSurfaceEntities(..., bindArguments:) instead.") +public enum RenderExtensionModelSurfaceSlot { + public static let fragmentBuffer0 = 10 + public static let fragmentBuffer1 = 11 + public static let fragmentBuffer2 = 12 + public static let fragmentBuffer3 = 13 + + public static let fragmentTexture0 = 10 + public static let fragmentTexture1 = 11 + public static let fragmentTexture2 = 12 + public static let fragmentTexture3 = 13 +} + +public enum RenderExtensionModelSurfaceArgument { + public static let argumentBufferIndex = 10 + + public static let texture0 = 0 + public static let texture1 = 1 + public static let texture2 = 2 + public static let texture3 = 3 + public static let texture4 = 4 + public static let texture5 = 5 + public static let texture6 = 6 + public static let texture7 = 7 + + public static let sampler0 = 8 + public static let sampler1 = 9 + public static let sampler2 = 10 + public static let sampler3 = 11 + public static let sampler4 = 12 + public static let sampler5 = 13 + public static let sampler6 = 14 + public static let sampler7 = 15 + + public static let buffer0 = 16 + public static let buffer1 = 17 + public static let buffer2 = 18 + public static let buffer3 = 19 + public static let buffer4 = 20 + public static let buffer5 = 21 + public static let buffer6 = 22 + public static let buffer7 = 23 + public static let buffer8 = 24 + public static let buffer9 = 25 + public static let buffer10 = 26 + public static let buffer11 = 27 + public static let buffer12 = 28 + public static let buffer13 = 29 + public static let buffer14 = 30 + public static let buffer15 = 31 +} + +public final class RenderModelSurfaceArgumentEncoder { + private let device: MTLDevice + private let argumentEncoder: MTLArgumentEncoder + private let argumentBuffer: MTLBuffer + private let resourceUsagesByID: [Int: MTLResourceUsage] + private var indirectResources: [(resource: any MTLResource, usage: MTLResourceUsage)] = [] + fileprivate var retainedResources: [AnyObject] = [] + + fileprivate init?( + device: MTLDevice, + argumentEncoder: MTLArgumentEncoder, + argumentDescriptors: [MTLArgumentDescriptor] + ) { + guard let argumentBuffer = device.makeBuffer( + length: argumentEncoder.encodedLength, + options: .storageModeShared + ) else { + Logger.logWarning(message: "[RenderExtension] Failed to allocate model-surface extension argument buffer") + return nil + } + + self.device = device + self.argumentEncoder = argumentEncoder + self.argumentBuffer = argumentBuffer + var resourceUsagesByID: [Int: MTLResourceUsage] = [:] + for descriptor in argumentDescriptors { + switch descriptor.dataType { + case .pointer, .texture: + resourceUsagesByID[descriptor.index] = Self.resourceUsage(for: descriptor.access) + default: + continue + } + } + self.resourceUsagesByID = resourceUsagesByID + argumentEncoder.setArgumentBuffer(argumentBuffer, offset: 0) + retainedResources.append(argumentBuffer) + } + + public func setTexture(_ texture: MTLTexture?, id: Int) { + guard RenderModelSurfaceArgumentEncoder.isTextureID(id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring model-surface extension texture argument outside 0...7: \(id)") + return + } + argumentEncoder.setTexture(texture, index: id) + if let texture { + retainIndirectResource(texture, id: id) + } + } + + public func setSampler(_ sampler: MTLSamplerState?, id: Int) { + guard RenderModelSurfaceArgumentEncoder.isSamplerID(id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring model-surface extension sampler argument outside 8...15: \(id)") + return + } + argumentEncoder.setSamplerState(sampler, index: id) + } + + public func setBuffer(_ buffer: MTLBuffer?, offset: Int = 0, id: Int) { + guard RenderModelSurfaceArgumentEncoder.isBufferID(id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring model-surface extension buffer argument outside 16...31: \(id)") + return + } + argumentEncoder.setBuffer(buffer, offset: offset, index: id) + if let buffer { + retainIndirectResource(buffer, id: id) + } + } + + public func setBytes(_ value: inout T, id: Int) { + guard RenderModelSurfaceArgumentEncoder.isBufferID(id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring model-surface extension bytes argument outside 16...31: \(id)") + return + } + + let length = MemoryLayout.stride + let buffer = withUnsafeBytes(of: &value) { rawValue in + device.makeBuffer(bytes: rawValue.baseAddress!, length: length, options: .storageModeShared) + } + guard let buffer else { + Logger.logWarning(message: "[RenderExtension] Failed to allocate model-surface extension bytes argument") + return + } + + argumentEncoder.setBuffer(buffer, offset: 0, index: id) + retainIndirectResource(buffer, id: id) + } + + fileprivate func bind(to renderEncoder: MTLRenderCommandEncoder) { + renderEncoder.setFragmentBuffer( + argumentBuffer, + offset: 0, + index: RenderExtensionModelSurfaceArgument.argumentBufferIndex + ) + for indirectResource in indirectResources { + renderEncoder.useResource( + indirectResource.resource, + usage: indirectResource.usage, + stages: .fragment + ) + } + } + + private func retainIndirectResource(_ resource: any MTLResource, id: Int) { + retainedResources.append(resource) + indirectResources.append(( + resource: resource, + usage: resourceUsagesByID[id] ?? .read + )) + } + + private static func resourceUsage(for access: MTLBindingAccess) -> MTLResourceUsage { + switch access { + case .readOnly: + return .read + case .writeOnly: + return .write + case .readWrite: + return [.read, .write] + @unknown default: + return .read + } + } + + private static func isTextureID(_ id: Int) -> Bool { + (RenderExtensionModelSurfaceArgument.texture0 ... RenderExtensionModelSurfaceArgument.texture7).contains(id) + } + + private static func isSamplerID(_ id: Int) -> Bool { + (RenderExtensionModelSurfaceArgument.sampler0 ... RenderExtensionModelSurfaceArgument.sampler7).contains(id) + } + + private static func isBufferID(_ id: Int) -> Bool { + (RenderExtensionModelSurfaceArgument.buffer0 ... RenderExtensionModelSurfaceArgument.buffer15).contains(id) + } +} + +public extension RenderPassContext { + func drawModelSurfaceEntities( + pipeline pipelineType: RenderPipelineType, + matching componentTypes: [any Component.Type] = [], + label: String = "Render Extension Model Surface", + argumentLayoutID: String? = nil, + bindEntity: RenderModelSurfaceEntityBinding? = nil, + bindArguments: RenderModelSurfaceArgumentBinding? = nil + ) { + guard let pipeline = PipelineManager.shared.renderPipelinesByType[pipelineType], + pipeline.success, + let pipelineState = pipeline.pipelineState + else { + Logger.logWarning(message: "[RenderExtension] Model surface pipeline '\(pipelineType.rawValue)' is not available") + return + } + + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = getEntityComponent(entityId: camera, componentType: CameraComponent.self) + else { + handleError(.noActiveCamera) + return + } + + guard let descriptor = renderInfo.deferredRenderPassDescriptor else { + handleError(.renderPassCreationFailed, label) + return + } + + descriptor.colorAttachments[0].loadAction = .load + descriptor.colorAttachments[0].storeAction = .store + descriptor.depthAttachment.loadAction = .load + descriptor.depthAttachment.storeAction = .store + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + handleError(.renderPassCreationFailed, label) + return + } + + defer { + renderEncoder.popDebugGroup() + renderEncoder.endEncoding() + } + + renderEncoder.label = label + renderEncoder.pushDebugGroup(label) + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setDepthStencilState(pipeline.depthState) + + let requiredComponents = modelSurfaceRequiredComponents(componentTypes) + let visibleEntities = Set(visibleEntityIds) + let entities = queryEntities(with: requiredComponents) + let argumentDescriptors = bindArguments == nil ? [] : modelSurfaceExtensionArgumentDescriptors( + argumentLayoutID: argumentLayoutID + ) + let argumentEncoder = bindArguments == nil ? nil : renderInfo.device.makeArgumentEncoder( + arguments: argumentDescriptors + ) + var retainedArgumentResources: [AnyObject] = [] + + for entityId in entities where visibleEntities.contains(entityId) { + guard shouldDrawModelSurfaceEntity(entityId) else { continue } + + guard let renderComponent = getEntityComponent(entityId: entityId, componentType: RenderComponent.self), + renderComponent.isVisible, + let worldTransform = getEntityComponent(entityId: entityId, componentType: WorldTransformComponent.self) + else { + continue + } + + bindEntity?(renderEncoder, entityId, resources) + if let bindArguments, let argumentEncoder { + let arguments = RenderModelSurfaceArgumentEncoder( + device: renderInfo.device, + argumentEncoder: argumentEncoder, + argumentDescriptors: argumentDescriptors + ) + if let arguments { + bindArguments(arguments, entityId, resources) + arguments.bind(to: renderEncoder) + retainedArgumentResources.append(contentsOf: arguments.retainedResources) + } + } + + drawModelSurfaceMeshes( + renderComponent.mesh, + entityId: entityId, + worldTransform: worldTransform, + cameraComponent: cameraComponent, + renderEncoder: renderEncoder + ) + } + + withExtendedLifetime(retainedArgumentResources) {} + } +} + +private func modelSurfaceExtensionArgumentDescriptors(argumentLayoutID: String?) -> [MTLArgumentDescriptor] { + if let argumentLayoutID { + guard let descriptor = RenderExtensionArgumentBufferRegistry.shared.descriptor(argumentLayoutID) else { + Logger.logWarning(message: "[RenderExtension] Missing model-surface extension argument layout '\(argumentLayoutID)'; using default layout") + return defaultModelSurfaceExtensionArgumentDescriptors() + } + return modelSurfaceExtensionArgumentDescriptors(from: descriptor) + } + + return defaultModelSurfaceExtensionArgumentDescriptors() +} + +private func defaultModelSurfaceExtensionArgumentDescriptors() -> [MTLArgumentDescriptor] { + var descriptors: [MTLArgumentDescriptor] = [] + + for id in RenderExtensionModelSurfaceArgument.texture0 ... RenderExtensionModelSurfaceArgument.texture7 { + descriptors.append(modelSurfaceExtensionTextureArgumentDescriptor( + id: id, + textureType: .type2D, + access: .readOnly + )) + } + + for id in RenderExtensionModelSurfaceArgument.sampler0 ... RenderExtensionModelSurfaceArgument.sampler7 { + descriptors.append(modelSurfaceExtensionSamplerArgumentDescriptor(id: id)) + } + + for id in RenderExtensionModelSurfaceArgument.buffer0 ... RenderExtensionModelSurfaceArgument.buffer15 { + descriptors.append(modelSurfaceExtensionBufferArgumentDescriptor(id: id, access: .readOnly)) + } + + return descriptors +} + +private func modelSurfaceExtensionArgumentDescriptors( + from descriptor: RenderExtensionArgumentBufferDescriptor +) -> [MTLArgumentDescriptor] { + var texturesByID: [Int: RenderExtensionArgumentTexture] = [:] + var buffersByID: [Int: RenderExtensionArgumentBuffer] = [:] + + for texture in descriptor.textures { + guard (RenderExtensionModelSurfaceArgument.texture0 ... RenderExtensionModelSurfaceArgument.texture7).contains(texture.id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring texture argument outside model-surface texture range in layout '\(descriptor.id)': \(texture.id)") + continue + } + texturesByID[texture.id] = texture + } + + for sampler in descriptor.samplers { + guard (RenderExtensionModelSurfaceArgument.sampler0 ... RenderExtensionModelSurfaceArgument.sampler7).contains(sampler.id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring sampler argument outside model-surface sampler range in layout '\(descriptor.id)': \(sampler.id)") + continue + } + } + + for buffer in descriptor.buffers { + guard (RenderExtensionModelSurfaceArgument.buffer0 ... RenderExtensionModelSurfaceArgument.buffer15).contains(buffer.id) else { + Logger.logWarning(message: "[RenderExtension] Ignoring buffer argument outside model-surface buffer range in layout '\(descriptor.id)': \(buffer.id)") + continue + } + buffersByID[buffer.id] = buffer + } + + // The shader support header exposes one fixed argument-buffer struct. Keep + // every ABI member in the encoder even when an extension uses only a subset. + var result: [MTLArgumentDescriptor] = [] + for id in RenderExtensionModelSurfaceArgument.texture0 ... RenderExtensionModelSurfaceArgument.texture7 { + let texture = texturesByID[id] + result.append(modelSurfaceExtensionTextureArgumentDescriptor( + id: id, + textureType: texture?.textureType ?? .type2D, + access: texture?.access ?? .readOnly + )) + } + + for id in RenderExtensionModelSurfaceArgument.sampler0 ... RenderExtensionModelSurfaceArgument.sampler7 { + result.append(modelSurfaceExtensionSamplerArgumentDescriptor(id: id)) + } + + for id in RenderExtensionModelSurfaceArgument.buffer0 ... RenderExtensionModelSurfaceArgument.buffer15 { + result.append(modelSurfaceExtensionBufferArgumentDescriptor( + id: id, + access: buffersByID[id]?.access ?? .readOnly + )) + } + + return result +} + +private func modelSurfaceExtensionTextureArgumentDescriptor( + id: Int, + textureType: MTLTextureType, + access: MTLBindingAccess +) -> MTLArgumentDescriptor { + let descriptor = MTLArgumentDescriptor() + descriptor.dataType = .texture + descriptor.index = id + descriptor.textureType = textureType + descriptor.access = access + return descriptor +} + +private func modelSurfaceExtensionSamplerArgumentDescriptor(id: Int) -> MTLArgumentDescriptor { + let descriptor = MTLArgumentDescriptor() + descriptor.dataType = .sampler + descriptor.index = id + return descriptor +} + +private func modelSurfaceExtensionBufferArgumentDescriptor( + id: Int, + access: MTLBindingAccess +) -> MTLArgumentDescriptor { + let descriptor = MTLArgumentDescriptor() + descriptor.dataType = .pointer + descriptor.index = id + descriptor.access = access + return descriptor +} + +private func modelSurfaceRequiredComponents( + _ componentTypes: [any Component.Type] +) -> [any Component.Type] { + var result = componentTypes + var seen = Set(componentTypes.map { ObjectIdentifier($0) }) + + for componentType in [RenderComponent.self, WorldTransformComponent.self] as [any Component.Type] { + let id = ObjectIdentifier(componentType) + if seen.insert(id).inserted { + result.append(componentType) + } + } + + return result +} + +private func shouldDrawModelSurfaceEntity(_ entityId: EntityID) -> Bool { + if scene.mask(for: entityId) == nil { return false } + if shouldHideSceneEntity(entityId: entityId) { return false } + if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { return false } + if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { return false } + if hasComponent(entityId: entityId, componentType: LightComponent.self) { return false } + if hasComponent(entityId: entityId, componentType: SceneCameraComponent.self) { return false } + if hasComponent(entityId: entityId, componentType: CameraComponent.self) { return false } + return true +} + +private func drawModelSurfaceMeshes( + _ meshes: [Mesh], + entityId: EntityID, + worldTransform: WorldTransformComponent, + cameraComponent: CameraComponent, + renderEncoder: MTLRenderCommandEncoder +) { + for mesh in meshes { + guard mesh.metalKitMesh.vertexBuffers.count > Int(modelPassJointWeightsIndex.rawValue) else { + continue + } + + var uniforms = Uniforms() + let modelMatrix = simd_mul(worldTransform.space, mesh.localSpace) + let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) + let upperModelMatrix = matrix3x3_upper_left(modelMatrix) + let normalMatrix = upperModelMatrix.inverse.transpose + + uniforms.modelViewMatrix = modelViewMatrix + uniforms.normalMatrix = normalMatrix + uniforms.viewMatrix = viewMatrix + uniforms.modelMatrix = modelMatrix + uniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + uniforms.projectionMatrix = renderInfo.perspectiveSpace + + renderEncoder.setVertexBytes( + &uniforms, + length: MemoryLayout.stride, + index: Int(modelPassUniformIndex.rawValue) + ) + + renderEncoder.setFragmentBytes( + &uniforms, + length: MemoryLayout.stride, + index: Int(modelPassFragmentUniformIndex.rawValue) + ) + + let jointTransformBuffer = mesh.skin?.jointTransformsBuffer + var hasArmature = getEntityComponent(entityId: entityId, componentType: SkeletonComponent.self) != nil && jointTransformBuffer != nil + renderEncoder.setVertexBytes( + &hasArmature, + length: MemoryLayout.stride, + index: Int(modelPassHasArmature.rawValue) + ) + + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, + offset: 0, + index: Int(modelPassVerticesIndex.rawValue) + ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassNormalIndex.rawValue)].buffer, + offset: 0, + index: Int(modelPassNormalIndex.rawValue) + ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassUVIndex.rawValue)].buffer, + offset: 0, + index: Int(modelPassUVIndex.rawValue) + ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassTangentIndex.rawValue)].buffer, + offset: 0, + index: Int(modelPassTangentIndex.rawValue) + ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassJointIdIndex.rawValue)].buffer, + offset: 0, + index: Int(modelPassJointIdIndex.rawValue) + ) + renderEncoder.setVertexBuffer( + mesh.metalKitMesh.vertexBuffers[Int(modelPassJointWeightsIndex.rawValue)].buffer, + offset: 0, + index: Int(modelPassJointWeightsIndex.rawValue) + ) + + if let jointTransformBuffer { + renderEncoder.setVertexBuffer( + jointTransformBuffer, + offset: 0, + index: Int(modelPassJointTransformIndex.rawValue) + ) + } else { + var identityMatrix = matrix_identity_float4x4 + renderEncoder.setVertexBytes( + &identityMatrix, + length: MemoryLayout.stride, + index: Int(modelPassJointTransformIndex.rawValue) + ) + } + + for submesh in mesh.submeshes { + renderEncoder.drawIndexedPrimitivesTracked( + type: submesh.metalKitSubmesh.primitiveType, + indexCount: submesh.metalKitSubmesh.indexCount, + indexType: submesh.metalKitSubmesh.indexType, + indexBuffer: submesh.metalKitSubmesh.indexBuffer.buffer, + indexBufferOffset: submesh.metalKitSubmesh.indexBuffer.offset, + category: .other + ) + } + } +} diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionPipelineDescriptors.swift b/Sources/UntoldEngine/Renderer/RenderExtensionPipelineDescriptors.swift new file mode 100644 index 00000000..a729b50a --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderExtensionPipelineDescriptors.swift @@ -0,0 +1,374 @@ +// +// RenderExtensionPipelineDescriptors.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import Metal + +/// Identifies the shader stage referenced by a pipeline validation error. +public enum RenderExtensionShaderStage: String, Equatable, Sendable { + case vertex + case fragment + case compute +} + +/// Declaratively describes an extension-owned render pipeline and its dependencies. +public struct RenderExtensionRenderPipelineDescriptor { + public let id: RenderPipelineType + public let vertexFunction: String + public let fragmentFunction: String? + public let vertexShaderLibrary: RenderShaderLibraryReference + public let fragmentShaderLibrary: RenderShaderLibraryReference + public let vertexDescriptor: MTLVertexDescriptor? + public let colorFormats: [MTLPixelFormat] + public let depthFormat: MTLPixelFormat + public let depthCompareFunction: MTLCompareFunction + public let depthEnabled: Bool + public let reverseZCompatible: Bool + public let blendMode: PipelineBlendMode + public let name: String + public let requiredArgumentLayoutID: String? + + public init( + id: RenderPipelineType, + vertexFunction: String, + fragmentFunction: String?, + vertexShaderLibrary: RenderShaderLibraryReference = .engine, + fragmentShaderLibrary: RenderShaderLibraryReference = .engine, + vertexDescriptor: MTLVertexDescriptor?, + colorFormats: [MTLPixelFormat], + depthFormat: MTLPixelFormat, + depthCompareFunction: MTLCompareFunction = .lessEqual, + depthEnabled: Bool = true, + reverseZCompatible: Bool = true, + blendMode: PipelineBlendMode = .none, + name: String, + requiredArgumentLayoutID: String? = nil + ) { + self.id = id + self.vertexFunction = vertexFunction + self.fragmentFunction = fragmentFunction + self.vertexShaderLibrary = vertexShaderLibrary + self.fragmentShaderLibrary = fragmentShaderLibrary + self.vertexDescriptor = vertexDescriptor + self.colorFormats = colorFormats + self.depthFormat = depthFormat + self.depthCompareFunction = depthCompareFunction + self.depthEnabled = depthEnabled + self.reverseZCompatible = reverseZCompatible + self.blendMode = blendMode + self.name = name + self.requiredArgumentLayoutID = requiredArgumentLayoutID + } +} + +/// Declaratively describes an extension-owned compute pipeline and its shader dependency. +public struct RenderExtensionComputePipelineDescriptor { + public let id: ComputePipelineType + public let function: String + public let shaderLibrary: RenderShaderLibraryReference + public let name: String + + public init( + id: ComputePipelineType, + function: String, + shaderLibrary: RenderShaderLibraryReference = .engine, + name: String + ) { + self.id = id + self.function = function + self.shaderLibrary = shaderLibrary + self.name = name + } +} + +/// Describes a pipeline recipe validation or Metal creation failure. +public enum RenderExtensionPipelineError: Error, Equatable, Sendable, CustomStringConvertible { + case emptyPipelineID(kind: RenderExtensionArtifactKind) + case emptyPipelineName(kind: RenderExtensionArtifactKind, pipelineID: String) + case emptyShaderFunction(pipelineID: String, stage: RenderExtensionShaderStage) + case missingShaderLibrary( + pipelineID: String, + stage: RenderExtensionShaderStage, + libraryID: String? + ) + case missingShaderFunction( + pipelineID: String, + stage: RenderExtensionShaderStage, + function: String + ) + case missingRenderTarget(pipelineID: String) + case tooManyColorAttachments(pipelineID: String, count: Int) + case invalidColorFormat(pipelineID: String, index: Int) + case invalidDepthFormat(pipelineID: String) + case missingArgumentLayout(pipelineID: String, layoutID: String) + case duplicatePipelineID(kind: RenderExtensionArtifactKind, pipelineID: String) + case creationFailed(kind: RenderExtensionArtifactKind, pipelineID: String) + + public var description: String { + switch self { + case let .emptyPipelineID(kind): + return "Extension \(kind.rawValue) IDs cannot be empty" + case let .emptyPipelineName(kind, pipelineID): + return "Extension \(kind.rawValue) '\(pipelineID)' must provide a name" + case let .emptyShaderFunction(pipelineID, stage): + return "Extension pipeline '\(pipelineID)' has an empty \(stage.rawValue) function" + case let .missingShaderLibrary(pipelineID, stage, libraryID): + let library = libraryID ?? "engine" + return "Extension pipeline '\(pipelineID)' cannot resolve \(stage.rawValue) shader library '\(library)'" + case let .missingShaderFunction(pipelineID, stage, function): + return "Extension pipeline '\(pipelineID)' cannot find \(stage.rawValue) function '\(function)'" + case let .missingRenderTarget(pipelineID): + return "Extension render pipeline '\(pipelineID)' must declare a color or depth target" + case let .tooManyColorAttachments(pipelineID, count): + return "Extension render pipeline '\(pipelineID)' declares \(count) color attachments; Metal supports at most 8" + case let .invalidColorFormat(pipelineID, index): + return "Extension render pipeline '\(pipelineID)' has an invalid color format at index \(index)" + case let .invalidDepthFormat(pipelineID): + return "Extension render pipeline '\(pipelineID)' enables depth writes without a depth format" + case let .missingArgumentLayout(pipelineID, layoutID): + return "Extension render pipeline '\(pipelineID)' references missing argument layout '\(layoutID)'" + case let .duplicatePipelineID(kind, pipelineID): + return "Extension declares \(kind.rawValue) '\(pipelineID)' more than once" + case let .creationFailed(kind, pipelineID): + return "Failed to create extension \(kind.rawValue) '\(pipelineID)'" + } + } +} + +final class RenderExtensionPipelineErrorCollector { + private(set) var errors: [RenderExtensionPipelineError] = [] + + func record(_ error: RenderExtensionPipelineError) { + if !errors.contains(error) { + errors.append(error) + } + } +} + +struct RenderExtensionPipelineRegistrationReport { + let conflicts: [RenderExtensionArtifactConflict] + let errors: [RenderExtensionPipelineError] +} + +protocol RenderExtensionPipelineCreating: AnyObject { + func makeRenderPipeline( + _ descriptor: RenderExtensionRenderPipelineDescriptor + ) -> RenderPipeline? + + func makeComputePipeline( + _ descriptor: RenderExtensionComputePipelineDescriptor, + library: MTLLibrary + ) -> ComputePipeline? +} + +private final class DefaultRenderExtensionPipelineCreator: RenderExtensionPipelineCreating { + func makeRenderPipeline( + _ descriptor: RenderExtensionRenderPipelineDescriptor + ) -> RenderPipeline? { + CreatePipeline( + vertexShader: descriptor.vertexFunction, + fragmentShader: descriptor.fragmentFunction, + vertexShaderLibrary: descriptor.vertexShaderLibrary, + fragmentShaderLibrary: descriptor.fragmentShaderLibrary, + vertexDescriptor: descriptor.vertexDescriptor, + colorFormats: descriptor.colorFormats, + depthFormat: descriptor.depthFormat, + depthCompareFunction: descriptor.depthCompareFunction, + depthEnabled: descriptor.depthEnabled, + reverseZCompatible: descriptor.reverseZCompatible, + blendMode: descriptor.blendMode, + name: descriptor.name + ) + } + + func makeComputePipeline( + _ descriptor: RenderExtensionComputePipelineDescriptor, + library: MTLLibrary + ) -> ComputePipeline? { + guard let device = renderInfo.device else { return nil } + var pipeline = ComputePipeline() + CreateComputePipeline( + into: &pipeline, + device: device, + library: library, + functionName: descriptor.function, + pipelineName: descriptor.name + ) + return pipeline.success ? pipeline : nil + } +} + +final class RenderExtensionPipelineCreator: @unchecked Sendable { + static let shared = RenderExtensionPipelineCreator() + + private let lock = NSLock() + private var creator: any RenderExtensionPipelineCreating = DefaultRenderExtensionPipelineCreator() + + private init() {} + + func makeRenderPipeline( + _ descriptor: RenderExtensionRenderPipelineDescriptor + ) -> RenderPipeline? { + lock.lock() + let creator = creator + lock.unlock() + return creator.makeRenderPipeline(descriptor) + } + + func makeComputePipeline( + _ descriptor: RenderExtensionComputePipelineDescriptor, + library: MTLLibrary + ) -> ComputePipeline? { + lock.lock() + let creator = creator + lock.unlock() + return creator.makeComputePipeline(descriptor, library: library) + } + + func replaceForTesting( + _ replacement: any RenderExtensionPipelineCreating + ) -> any RenderExtensionPipelineCreating { + lock.lock() + let previous = creator + creator = replacement + lock.unlock() + return previous + } +} + +func validateRenderExtensionRenderPipeline( + _ descriptor: RenderExtensionRenderPipelineDescriptor +) -> [RenderExtensionPipelineError] { + let pipelineID = descriptor.id.rawValue + var errors = validatePipelineIdentity( + kind: .renderPipeline, + pipelineID: pipelineID, + name: descriptor.name + ) + validateShader( + pipelineID: pipelineID, + stage: .vertex, + function: descriptor.vertexFunction, + reference: descriptor.vertexShaderLibrary, + errors: &errors + ) + if let fragmentFunction = descriptor.fragmentFunction { + validateShader( + pipelineID: pipelineID, + stage: .fragment, + function: fragmentFunction, + reference: descriptor.fragmentShaderLibrary, + errors: &errors + ) + } + if descriptor.colorFormats.isEmpty, descriptor.depthFormat == .invalid { + errors.append(.missingRenderTarget(pipelineID: pipelineID)) + } + if descriptor.colorFormats.count > 8 { + errors.append( + .tooManyColorAttachments( + pipelineID: pipelineID, + count: descriptor.colorFormats.count + ) + ) + } + for (index, format) in descriptor.colorFormats.enumerated() where format == .invalid { + errors.append(.invalidColorFormat(pipelineID: pipelineID, index: index)) + } + if descriptor.depthEnabled, descriptor.depthFormat == .invalid { + errors.append(.invalidDepthFormat(pipelineID: pipelineID)) + } + if let layoutID = descriptor.requiredArgumentLayoutID, + RenderExtensionArgumentBufferRegistry.shared.descriptor(layoutID) == nil + { + errors.append(.missingArgumentLayout(pipelineID: pipelineID, layoutID: layoutID)) + } + return errors +} + +func validateRenderExtensionComputePipeline( + _ descriptor: RenderExtensionComputePipelineDescriptor +) -> (errors: [RenderExtensionPipelineError], library: MTLLibrary?) { + let pipelineID = descriptor.id.rawValue + var errors = validatePipelineIdentity( + kind: .computePipeline, + pipelineID: pipelineID, + name: descriptor.name + ) + let library = validateShader( + pipelineID: pipelineID, + stage: .compute, + function: descriptor.function, + reference: descriptor.shaderLibrary, + errors: &errors + ) + return (errors, library) +} + +private func validatePipelineIdentity( + kind: RenderExtensionArtifactKind, + pipelineID: String, + name: String +) -> [RenderExtensionPipelineError] { + var errors: [RenderExtensionPipelineError] = [] + if pipelineID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyPipelineID(kind: kind)) + } + if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyPipelineName(kind: kind, pipelineID: pipelineID)) + } + return errors +} + +@discardableResult +private func validateShader( + pipelineID: String, + stage: RenderExtensionShaderStage, + function: String, + reference: RenderShaderLibraryReference, + errors: inout [RenderExtensionPipelineError] +) -> MTLLibrary? { + if function.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyShaderFunction(pipelineID: pipelineID, stage: stage)) + return nil + } + + let library: MTLLibrary? + let libraryID: String? + switch reference { + case .engine: + library = renderInfo.library + libraryID = nil + case let .registered(id): + library = RenderShaderLibraryManager.shared.library(id) + libraryID = id.rawValue + } + guard let library else { + errors.append( + .missingShaderLibrary( + pipelineID: pipelineID, + stage: stage, + libraryID: libraryID + ) + ) + return nil + } + guard library.makeFunction(name: function) != nil else { + errors.append( + .missingShaderFunction( + pipelineID: pipelineID, + stage: stage, + function: function + ) + ) + return library + } + return library +} diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionPlugins.swift b/Sources/UntoldEngine/Renderer/RenderExtensionPlugins.swift new file mode 100644 index 00000000..65262f62 --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderExtensionPlugins.swift @@ -0,0 +1,586 @@ +// +// RenderExtensionPlugins.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation + +/// Identifies the rendering-extension contract understood by the engine and a plugin. +public struct RenderExtensionAPIVersion: RawRepresentable, Hashable, Codable, Comparable, Sendable { + /// The extension API implemented by this engine build. + public static let current = RenderExtensionAPIVersion(1) + + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public init(_ rawValue: UInt32) { + self.rawValue = rawValue + } + + public static func < (lhs: RenderExtensionAPIVersion, rhs: RenderExtensionAPIVersion) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// A plugin's release version. +public struct RenderExtensionPluginVersion: Hashable, Codable, Comparable, Sendable, CustomStringConvertible { + public let major: UInt32 + public let minor: UInt32 + public let patch: UInt32 + + public init(major: UInt32, minor: UInt32, patch: UInt32) { + self.major = major + self.minor = minor + self.patch = patch + } + + public static func < ( + lhs: RenderExtensionPluginVersion, + rhs: RenderExtensionPluginVersion + ) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + public var description: String { + "\(major).\(minor).\(patch)" + } +} + +/// Declares a plugin's stable identity, release version, and required engine contract. +public struct RenderExtensionPluginManifest: Hashable, Codable, Sendable { + public let id: String + public let displayName: String + public let version: RenderExtensionPluginVersion + public let requiredAPIVersion: RenderExtensionAPIVersion + + public init( + id: String, + displayName: String, + version: RenderExtensionPluginVersion, + requiredAPIVersion: RenderExtensionAPIVersion = .current + ) { + self.id = id + self.displayName = displayName + self.version = version + self.requiredAPIVersion = requiredAPIVersion + } +} + +/// A statically linked package or framework that provides one or more rendering extensions. +public protocol RenderExtensionPlugin: Sendable { + var manifest: RenderExtensionPluginManifest { get } + + /// Creates the extensions supplied by this plugin for contract validation or installation. + func makeRenderExtensions() -> [any RenderExtension] +} + +/// Describes a violation of the rendering-extension plugin contract. +public enum RenderExtensionPluginValidationError: Error, Equatable, Sendable, CustomStringConvertible { + case emptyPluginID + case pluginIDMustBeNamespaced(String) + case emptyDisplayName(pluginID: String) + case unsupportedAPIVersion( + pluginID: String, + required: RenderExtensionAPIVersion, + supported: RenderExtensionAPIVersion + ) + case noExtensions(pluginID: String) + case emptyExtensionID(pluginID: String) + case duplicateExtensionID(pluginID: String, extensionID: String) + case extensionIDOutsidePluginNamespace(pluginID: String, extensionID: String) + case duplicatePluginID(String) + + public var description: String { + switch self { + case .emptyPluginID: + return "Render extension plugin IDs cannot be empty" + case let .pluginIDMustBeNamespaced(id): + return "Render extension plugin ID '\(id)' must be a dot-separated package namespace" + case let .emptyDisplayName(pluginID): + return "Render extension plugin '\(pluginID)' must provide a display name" + case let .unsupportedAPIVersion(pluginID, required, supported): + return "Render extension plugin '\(pluginID)' requires API version \(required.rawValue), but the engine supports version \(supported.rawValue)" + case let .noExtensions(pluginID): + return "Render extension plugin '\(pluginID)' does not provide any extensions" + case let .emptyExtensionID(pluginID): + return "Render extension plugin '\(pluginID)' contains an extension with an empty ID" + case let .duplicateExtensionID(pluginID, extensionID): + return "Render extension plugin '\(pluginID)' provides extension ID '\(extensionID)' more than once" + case let .extensionIDOutsidePluginNamespace(pluginID, extensionID): + return "Render extension ID '\(extensionID)' must equal or begin with plugin namespace '\(pluginID)'" + case let .duplicatePluginID(pluginID): + return "Render extension plugin ID '\(pluginID)' is declared more than once" + } + } +} + +/// The complete deterministic result of validating one or more plugin contracts. +public struct RenderExtensionPluginValidationReport: Equatable, Sendable { + public let errors: [RenderExtensionPluginValidationError] + + public init(errors: [RenderExtensionPluginValidationError]) { + self.errors = errors + } + + public var isValid: Bool { + errors.isEmpty + } +} + +/// Validates plugin metadata and extension identity without registering engine artifacts. +public enum RenderExtensionPluginValidator { + /// Validates one plugin against the extension API supported by the engine. + public static func validate( + _ plugin: any RenderExtensionPlugin, + supportedAPIVersion: RenderExtensionAPIVersion = .current + ) -> RenderExtensionPluginValidationReport { + validate( + manifest: plugin.manifest, + extensions: plugin.makeRenderExtensions(), + supportedAPIVersion: supportedAPIVersion + ) + } + + /// Validates a plugin collection, including duplicate plugin identities. + public static func validate( + _ plugins: [any RenderExtensionPlugin], + supportedAPIVersion: RenderExtensionAPIVersion = .current + ) -> RenderExtensionPluginValidationReport { + var errors: [RenderExtensionPluginValidationError] = [] + var seenPluginIDs: Set = [] + var duplicatePluginIDs: Set = [] + + for plugin in plugins { + let manifest = plugin.manifest + let pluginID = manifest.id + let renderExtensions = plugin.makeRenderExtensions() + errors.append(contentsOf: validate( + manifest: manifest, + extensions: renderExtensions, + supportedAPIVersion: supportedAPIVersion + ).errors) + let trimmedPluginID = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedPluginID.isEmpty, + !seenPluginIDs.insert(pluginID).inserted, + duplicatePluginIDs.insert(pluginID).inserted + { + errors.append(.duplicatePluginID(pluginID)) + } + } + + return RenderExtensionPluginValidationReport(errors: errors) + } + + static func validate( + manifest: RenderExtensionPluginManifest, + extensions: [any RenderExtension], + supportedAPIVersion: RenderExtensionAPIVersion = .current + ) -> RenderExtensionPluginValidationReport { + let pluginID = manifest.id + let trimmedPluginID = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + let hasValidPluginID = isValidNamespace(pluginID) + var errors: [RenderExtensionPluginValidationError] = [] + + if trimmedPluginID.isEmpty { + appendUnique(.emptyPluginID, to: &errors) + } else if !hasValidPluginID { + appendUnique(.pluginIDMustBeNamespaced(pluginID), to: &errors) + } + if manifest.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyDisplayName(pluginID: pluginID)) + } + if manifest.requiredAPIVersion != supportedAPIVersion { + errors.append( + .unsupportedAPIVersion( + pluginID: pluginID, + required: manifest.requiredAPIVersion, + supported: supportedAPIVersion + ) + ) + } + if extensions.isEmpty { + errors.append(.noExtensions(pluginID: pluginID)) + return RenderExtensionPluginValidationReport(errors: errors) + } + + var seenExtensionIDs: Set = [] + for renderExtension in extensions { + let extensionID = renderExtension.id + if extensionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + appendUnique(.emptyExtensionID(pluginID: pluginID), to: &errors) + continue + } + if !seenExtensionIDs.insert(extensionID).inserted { + appendUnique( + .duplicateExtensionID(pluginID: pluginID, extensionID: extensionID), + to: &errors + ) + } + if hasValidPluginID, + extensionID != pluginID, + !extensionID.hasPrefix("\(pluginID).") + { + errors.append( + .extensionIDOutsidePluginNamespace( + pluginID: pluginID, + extensionID: extensionID + ) + ) + } + } + return RenderExtensionPluginValidationReport(errors: errors) + } + + private static func isValidNamespace(_ id: String) -> Bool { + guard id == id.trimmingCharacters(in: .whitespacesAndNewlines) else { return false } + let components = id.split(separator: ".", omittingEmptySubsequences: false) + guard components.count >= 2 else { return false } + let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + return components.allSatisfy { component in + !component.isEmpty && component.unicodeScalars.allSatisfy(allowedCharacters.contains) + } + } + + private static func appendUnique( + _ error: RenderExtensionPluginValidationError, + to errors: inout [RenderExtensionPluginValidationError] + ) { + if !errors.contains(error) { + errors.append(error) + } + } +} + +/// Associates a failed extension registration with its plugin member ID. +public struct RenderExtensionPluginExtensionFailure: Equatable, Sendable { + public let extensionID: String + public let result: RenderExtensionRegistrationResult + + public init(extensionID: String, result: RenderExtensionRegistrationResult) { + self.extensionID = extensionID + self.result = result + } +} + +/// Collects contract, ownership, artifact, and graph failures for a plugin transaction. +public struct RenderExtensionPluginFailure: Equatable, Sendable { + public let validationErrors: [RenderExtensionPluginValidationError] + public let conflictingExtensionIDs: [String] + public let extensionFailures: [RenderExtensionPluginExtensionFailure] + public let artifactConflicts: [RenderExtensionArtifactConflict] + public let graphValidationErrors: [RenderGraphError] + + public init( + validationErrors: [RenderExtensionPluginValidationError] = [], + conflictingExtensionIDs: [String] = [], + extensionFailures: [RenderExtensionPluginExtensionFailure] = [], + artifactConflicts: [RenderExtensionArtifactConflict] = [], + graphValidationErrors: [RenderGraphError] = [] + ) { + self.validationErrors = validationErrors + self.conflictingExtensionIDs = conflictingExtensionIDs + self.extensionFailures = extensionFailures + self.artifactConflicts = artifactConflicts + self.graphValidationErrors = graphValidationErrors + } +} + +/// Reports whether a plugin was installed, replaced, or rejected atomically. +public enum RenderExtensionPluginInstallationResult: Equatable, Sendable { + case installed + case replaced + case rejected(RenderExtensionPluginFailure) +} + +private struct InstalledRenderExtensionPlugin { + let manifest: RenderExtensionPluginManifest + let extensions: [any RenderExtension] +} + +/// Installs and removes complete rendering-extension plugins as serialized transactions. +public final class RenderExtensionPluginRegistry: @unchecked Sendable { + public static let shared = RenderExtensionPluginRegistry() + + private let lock = NSLock() + private var pluginsByID: [String: InstalledRenderExtensionPlugin] = [:] + private var pluginOrder: [String] = [] + private var pluginIDByExtensionID: [String: String] = [:] + private var failuresByPluginID: [String: RenderExtensionPluginFailure] = [:] + + private init() {} + + /// Validates and atomically installs or replaces a plugin. + @discardableResult + public func install( + _ plugin: any RenderExtensionPlugin + ) -> RenderExtensionPluginInstallationResult { + let manifest = plugin.manifest + let extensions = plugin.makeRenderExtensions() + let validation = RenderExtensionPluginValidator.validate( + manifest: manifest, + extensions: extensions + ) + guard validation.isValid else { + let failure = RenderExtensionPluginFailure(validationErrors: validation.errors) + storeFailure(failure, pluginID: manifest.id) + return .rejected(failure) + } + + return RenderExtensionRegistry.shared.performPluginLifecycle { + installValidatedPlugin(manifest: manifest, extensions: extensions) + } + } + + /// Removes a plugin and every artifact owned by its extensions. + public func uninstall(id: String) { + RenderExtensionRegistry.shared.performPluginLifecycle { + lock.lock() + let installedPlugin = pluginsByID.removeValue(forKey: id) + pluginOrder.removeAll { $0 == id } + failuresByPluginID.removeValue(forKey: id) + if let installedPlugin { + for renderExtension in installedPlugin.extensions { + pluginIDByExtensionID.removeValue(forKey: renderExtension.id) + } + } + lock.unlock() + + for renderExtension in installedPlugin?.extensions ?? [] { + RenderExtensionRegistry.shared.unregisterPluginOwnedExtension( + id: renderExtension.id + ) + } + } + } + + /// Removes all installed plugins without removing standalone extensions. + public func removeAll() { + RenderExtensionRegistry.shared.performPluginLifecycle { + lock.lock() + let installedPlugins = pluginOrder.compactMap { pluginsByID[$0] } + pluginsByID.removeAll() + pluginOrder.removeAll() + pluginIDByExtensionID.removeAll() + failuresByPluginID.removeAll() + lock.unlock() + + for installedPlugin in installedPlugins { + for renderExtension in installedPlugin.extensions { + RenderExtensionRegistry.shared.unregisterPluginOwnedExtension( + id: renderExtension.id + ) + } + } + } + } + + public func installedPluginIDs() -> [String] { + lock.lock() + let ids = pluginOrder + lock.unlock() + return ids + } + + public func installedManifests() -> [RenderExtensionPluginManifest] { + lock.lock() + let manifests = pluginOrder.compactMap { pluginsByID[$0]?.manifest } + lock.unlock() + return manifests + } + + public func failure(forPluginID id: String) -> RenderExtensionPluginFailure? { + lock.lock() + let failure = failuresByPluginID[id] + lock.unlock() + return failure + } + + func installedPluginID(containingExtensionID extensionID: String) -> String? { + lock.lock() + let pluginID = pluginIDByExtensionID[extensionID] + lock.unlock() + return pluginID + } + + func removeAllMetadata() { + lock.lock() + pluginsByID.removeAll() + pluginOrder.removeAll() + pluginIDByExtensionID.removeAll() + failuresByPluginID.removeAll() + lock.unlock() + } + + func invalidateInstalledPlugin( + containingExtensionID extensionID: String, + artifactConflicts: [RenderExtensionArtifactConflict] = [], + shaderLibraryErrors: [RenderShaderLibraryLoadingError] = [], + pipelineErrors: [RenderExtensionPipelineError] = [], + graphValidationErrors: [RenderGraphError] = [] + ) -> [String] { + lock.lock() + guard let pluginID = pluginIDByExtensionID[extensionID], + let installedPlugin = pluginsByID.removeValue(forKey: pluginID) + else { + lock.unlock() + return [] + } + let extensionIDs = installedPlugin.extensions.map(\.id) + pluginOrder.removeAll { $0 == pluginID } + for ownedExtensionID in extensionIDs { + pluginIDByExtensionID.removeValue(forKey: ownedExtensionID) + } + let extensionFailures: [RenderExtensionPluginExtensionFailure] + if shaderLibraryErrors.isEmpty, pipelineErrors.isEmpty { + extensionFailures = [] + } else { + extensionFailures = [ + RenderExtensionPluginExtensionFailure( + extensionID: extensionID, + result: .rejectedArtifacts( + conflicts: artifactConflicts, + shaderLibraryErrors: shaderLibraryErrors, + pipelineErrors: pipelineErrors, + resourceValidationErrors: [] + ) + ), + ] + } + failuresByPluginID[pluginID] = RenderExtensionPluginFailure( + extensionFailures: extensionFailures, + artifactConflicts: artifactConflicts, + graphValidationErrors: graphValidationErrors + ) + lock.unlock() + return extensionIDs + } + + private func installValidatedPlugin( + manifest: RenderExtensionPluginManifest, + extensions: [any RenderExtension] + ) -> RenderExtensionPluginInstallationResult { + lock.lock() + let previousPlugin = pluginsByID[manifest.id] + lock.unlock() + + let previousExtensionIDs = Set(previousPlugin?.extensions.map(\.id) ?? []) + let newExtensionIDs = extensions.map(\.id) + let registeredExtensionIDs = Set(RenderExtensionRegistry.shared.registeredIDs()) + let conflictingExtensionIDs = newExtensionIDs.filter { + registeredExtensionIDs.contains($0) && !previousExtensionIDs.contains($0) + } + guard conflictingExtensionIDs.isEmpty else { + let failure = RenderExtensionPluginFailure( + conflictingExtensionIDs: conflictingExtensionIDs + ) + storeFailure(failure, pluginID: manifest.id) + return .rejected(failure) + } + + let previousGlobalOrder = RenderExtensionRegistry.shared.registeredIDs() + let newExtensionIDSet = Set(newExtensionIDs) + let removedPreviousExtensions = previousPlugin?.extensions.filter { + !newExtensionIDSet.contains($0.id) + } ?? [] + for renderExtension in removedPreviousExtensions { + RenderExtensionRegistry.shared.unregisterPluginOwnedExtension(id: renderExtension.id) + } + + var successfullyRegisteredIDs: [String] = [] + for renderExtension in extensions { + let result = RenderExtensionRegistry.shared.registerPluginOwnedExtension(renderExtension) + guard result == .registered else { + rollbackFailedInstallation( + previousPlugin: previousPlugin, + previousGlobalOrder: previousGlobalOrder, + successfullyRegisteredIDs: successfullyRegisteredIDs + ) + let failure = RenderExtensionPluginFailure( + extensionFailures: [ + RenderExtensionPluginExtensionFailure( + extensionID: renderExtension.id, + result: result + ), + ] + ) + storeFailure(failure, pluginID: manifest.id) + return .rejected(failure) + } + successfullyRegisteredIDs.append(renderExtension.id) + } + + let desiredGlobalOrder = replacingPluginExtensionIDs( + previousExtensionIDs: previousExtensionIDs, + newExtensionIDs: newExtensionIDs, + in: previousGlobalOrder + ) + RenderExtensionRegistry.shared.setRegisteredExtensionOrder(desiredGlobalOrder) + + lock.lock() + if previousPlugin == nil { + pluginOrder.append(manifest.id) + } + if let previousPlugin { + for renderExtension in previousPlugin.extensions { + pluginIDByExtensionID.removeValue(forKey: renderExtension.id) + } + } + pluginsByID[manifest.id] = InstalledRenderExtensionPlugin( + manifest: manifest, + extensions: extensions + ) + for renderExtension in extensions { + pluginIDByExtensionID[renderExtension.id] = manifest.id + } + failuresByPluginID.removeValue(forKey: manifest.id) + lock.unlock() + return previousPlugin == nil ? .installed : .replaced + } + + private func rollbackFailedInstallation( + previousPlugin: InstalledRenderExtensionPlugin?, + previousGlobalOrder: [String], + successfullyRegisteredIDs: [String] + ) { + let previousExtensionsByID = Dictionary( + uniqueKeysWithValues: (previousPlugin?.extensions ?? []).map { ($0.id, $0) } + ) + for extensionID in successfullyRegisteredIDs where previousExtensionsByID[extensionID] == nil { + RenderExtensionRegistry.shared.unregisterPluginOwnedExtension(id: extensionID) + } + for renderExtension in previousPlugin?.extensions ?? [] { + _ = RenderExtensionRegistry.shared.registerPluginOwnedExtension(renderExtension) + } + RenderExtensionRegistry.shared.setRegisteredExtensionOrder(previousGlobalOrder) + } + + private func replacingPluginExtensionIDs( + previousExtensionIDs: Set, + newExtensionIDs: [String], + in globalOrder: [String] + ) -> [String] { + guard !previousExtensionIDs.isEmpty else { + return globalOrder + newExtensionIDs + } + let insertionIndex = globalOrder.prefix { !previousExtensionIDs.contains($0) }.count + var result = globalOrder.filter { !previousExtensionIDs.contains($0) } + result.insert(contentsOf: newExtensionIDs, at: min(insertionIndex, result.count)) + return result + } + + private func storeFailure(_ failure: RenderExtensionPluginFailure, pluginID: String) { + lock.lock() + failuresByPluginID[pluginID] = failure + lock.unlock() + } +} diff --git a/Sources/UntoldEngine/Renderer/RenderExtensions.swift b/Sources/UntoldEngine/Renderer/RenderExtensions.swift new file mode 100644 index 00000000..c064822b --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderExtensions.swift @@ -0,0 +1,2665 @@ +// +// RenderExtensions.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import Metal + +public enum RenderExtensionArtifactKind: String, Hashable, Sendable { + case shaderLibrary + case renderPipeline + case computePipeline + case texture + case buffer + case argumentBuffer + case renderPass +} + +public struct RenderExtensionArtifactConflict: Error, Equatable, Sendable, CustomStringConvertible { + public let kind: RenderExtensionArtifactKind + public let artifactID: String + public let requestedOwnerID: String + public let existingOwnerID: String? + + public init( + kind: RenderExtensionArtifactKind, + artifactID: String, + requestedOwnerID: String, + existingOwnerID: String? + ) { + self.kind = kind + self.artifactID = artifactID + self.requestedOwnerID = requestedOwnerID + self.existingOwnerID = existingOwnerID + } + + public var description: String { + let owner = existingOwnerID ?? "engine or unscoped registration" + return "Extension '\(requestedOwnerID)' cannot register \(kind.rawValue) '\(artifactID)'; it is owned by \(owner)" + } +} + +public enum RenderExtensionRegistrationResult: Equatable, Sendable { + case registered + case rejectedPluginOwnership(extensionID: String, pluginID: String) + case rejected([RenderExtensionArtifactConflict]) + case rejectedResources( + conflicts: [RenderExtensionArtifactConflict], + validationErrors: [RenderExtensionResourceValidationError] + ) + case rejectedArtifacts( + conflicts: [RenderExtensionArtifactConflict], + shaderLibraryErrors: [RenderShaderLibraryLoadingError], + pipelineErrors: [RenderExtensionPipelineError], + resourceValidationErrors: [RenderExtensionResourceValidationError] + ) + + public var conflicts: [RenderExtensionArtifactConflict] { + switch self { + case .registered, .rejectedPluginOwnership: + return [] + case let .rejected(conflicts): + return conflicts + case let .rejectedResources(conflicts, _): + return conflicts + case let .rejectedArtifacts(conflicts, _, _, _): + return conflicts + } + } + + public var resourceValidationErrors: [RenderExtensionResourceValidationError] { + switch self { + case .registered, .rejectedPluginOwnership, .rejected: + return [] + case let .rejectedResources(_, validationErrors): + return validationErrors + case let .rejectedArtifacts(_, _, _, validationErrors): + return validationErrors + } + } + + public var shaderLibraryErrors: [RenderShaderLibraryLoadingError] { + switch self { + case .registered, .rejectedPluginOwnership, .rejected, .rejectedResources: + return [] + case let .rejectedArtifacts(_, loadingErrors, _, _): + return loadingErrors + } + } + + public var pipelineErrors: [RenderExtensionPipelineError] { + switch self { + case .registered, .rejectedPluginOwnership, .rejected, .rejectedResources: + return [] + case let .rejectedArtifacts(_, _, pipelineErrors, _): + return pipelineErrors + } + } +} + +final class RenderExtensionConflictCollector { + private(set) var conflicts: [RenderExtensionArtifactConflict] = [] + + func record(_ conflict: RenderExtensionArtifactConflict) { + if !conflicts.contains(conflict) { + conflicts.append(conflict) + } + } +} + +public protocol RenderExtension: AnyObject, Sendable { + var id: String { get } + + func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) + + func registerPipelines(_ registry: RenderPipelineRegistry) + + func registerComputePipelines(_ registry: ComputePipelineRegistry) + + func registerResources(_ registry: RenderResourceRegistry) + + func registerArgumentBuffers(_ registry: RenderExtensionArgumentBufferRegistry) + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context: RenderGraphBuildContext + ) +} + +public extension RenderExtension { + func registerShaderLibraries(_: RenderShaderLibraryRegistry) {} + func registerPipelines(_: RenderPipelineRegistry) {} + func registerComputePipelines(_: ComputePipelineRegistry) {} + func registerResources(_: RenderResourceRegistry) {} + func registerArgumentBuffers(_: RenderExtensionArgumentBufferRegistry) {} +} + +public struct RenderShaderLibraryID: Hashable, ExpressibleByStringLiteral, Sendable { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } +} + +/// Describes where an extension shader library is loaded from. +public enum RenderShaderLibrarySource { + /// Uses an already-created Metal library. + case library(MTLLibrary) + /// Loads the default Metal library compiled into a bundle. + case defaultLibrary(bundle: Bundle) + /// Loads a precompiled `.metallib` resource relative to a bundle. + case metallib(bundle: Bundle, resource: String, subdirectory: String? = nil) +} + +/// Describes a structured extension shader-library loading failure. +public enum RenderShaderLibraryLoadingError: Error, Equatable, Sendable, CustomStringConvertible { + case metalUnavailable(libraryID: RenderShaderLibraryID) + case resourceNotFound( + libraryID: RenderShaderLibraryID, + resource: String, + subdirectory: String? + ) + case defaultLibraryCreationFailed(libraryID: RenderShaderLibraryID, bundlePath: String) + case metallibCreationFailed( + libraryID: RenderShaderLibraryID, + resource: String, + subdirectory: String? + ) + case libraryCreationFailed(libraryID: RenderShaderLibraryID, url: URL) + + public var description: String { + switch self { + case let .metalUnavailable(libraryID): + return "Cannot load shader library '\(libraryID.rawValue)' before Metal is ready" + case let .resourceNotFound(libraryID, resource, subdirectory): + let location = subdirectory.map { " in '\($0)'" } ?? "" + return "Shader library '\(libraryID.rawValue)' cannot find bundled metallib '\(resource).metallib'\(location)" + case let .defaultLibraryCreationFailed(libraryID, bundlePath): + return "Failed to create default shader library '\(libraryID.rawValue)' from bundle '\(bundlePath)'" + case let .metallibCreationFailed(libraryID, resource, subdirectory): + let location = subdirectory.map { " in '\($0)'" } ?? "" + return "Failed to create shader library '\(libraryID.rawValue)' from bundled metallib '\(resource).metallib'\(location)" + case let .libraryCreationFailed(libraryID, url): + return "Failed to create shader library '\(libraryID.rawValue)' from '\(url.path)'" + } + } +} + +private final class RenderShaderLibraryLoadingErrorCollector { + private(set) var errors: [RenderShaderLibraryLoadingError] = [] + + func record(_ error: RenderShaderLibraryLoadingError) { + if !errors.contains(error) { + errors.append(error) + } + } +} + +protocol RenderShaderLibraryLoading: AnyObject { + func resourceURL( + in bundle: Bundle, + resource: String, + subdirectory: String? + ) -> URL? + + func makeDefaultLibrary(device: MTLDevice, bundle: Bundle) throws -> MTLLibrary + func makeLibrary(device: MTLDevice, url: URL) throws -> MTLLibrary +} + +private final class DefaultRenderShaderLibraryLoader: RenderShaderLibraryLoading { + func resourceURL( + in bundle: Bundle, + resource: String, + subdirectory: String? + ) -> URL? { + bundle.url( + forResource: resource, + withExtension: "metallib", + subdirectory: subdirectory + ) + } + + func makeDefaultLibrary(device: MTLDevice, bundle: Bundle) throws -> MTLLibrary { + try device.makeDefaultLibrary(bundle: bundle) + } + + func makeLibrary(device: MTLDevice, url: URL) throws -> MTLLibrary { + try device.makeLibrary(URL: url) + } +} + +private struct RenderShaderLibraryRegistrationReport { + let conflicts: [RenderExtensionArtifactConflict] + let loadingErrors: [RenderShaderLibraryLoadingError] +} + +public enum RenderShaderLibraryReference: Sendable { + case engine + case registered(RenderShaderLibraryID) +} + +public enum RenderExtensionModelSurfacePipelineValidation: Sendable { + case disabled + case warn(argumentLayoutID: String? = nil) +} + +struct RenderExtensionShaderArgument { + let name: String + let index: Int + let type: MTLBindingType +} + +func resolveRenderShaderLibrary( + _ reference: RenderShaderLibraryReference, + usage: String +) -> MTLLibrary? { + switch reference { + case .engine: + guard let library = renderInfo.library else { + handleError(.metalLibraryNotFound) + return nil + } + return library + case let .registered(id): + guard let library = RenderShaderLibraryManager.shared.library(id) else { + Logger.logWarning(message: "[RenderExtension] Missing shader library '\(id.rawValue)' for \(usage)") + return nil + } + return library + } +} + +public final class RenderShaderLibraryManager: @unchecked Sendable { + public static let shared = RenderShaderLibraryManager() + + private let lock = NSLock() + private let registrationLock = NSRecursiveLock() + private var librariesByID: [RenderShaderLibraryID: MTLLibrary] = [:] + private var libraryOwners: [RenderShaderLibraryID: String] = [:] + private var currentRegistrationOwnerID: String? + private var currentConflictCollector: RenderExtensionConflictCollector? + private var currentLoadingErrorCollector: RenderShaderLibraryLoadingErrorCollector? + private var loader: any RenderShaderLibraryLoading = DefaultRenderShaderLibraryLoader() + + private init() {} + + public func library(_ id: RenderShaderLibraryID) -> MTLLibrary? { + lock.lock() + let library = librariesByID[id] + lock.unlock() + return library + } + + func update(_ library: MTLLibrary, forID id: RenderShaderLibraryID) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + if let ownerID = currentRegistrationOwnerID, + librariesByID[id] != nil, + libraryOwners[id] != ownerID + { + currentConflictCollector?.record( + RenderExtensionArtifactConflict( + kind: .shaderLibrary, + artifactID: id.rawValue, + requestedOwnerID: ownerID, + existingOwnerID: libraryOwners[id] + ) + ) + lock.unlock() + return + } + librariesByID[id] = library + if let currentRegistrationOwnerID { + libraryOwners[id] = currentRegistrationOwnerID + } else { + libraryOwners.removeValue(forKey: id) + } + lock.unlock() + } + + func load(_ id: RenderShaderLibraryID, source: RenderShaderLibrarySource) { + registrationLock.lock() + defer { registrationLock.unlock() } + + switch source { + case let .library(library): + update(library, forID: id) + case let .defaultLibrary(bundle): + guard let device = renderInfo.device else { + recordLoadingError(.metalUnavailable(libraryID: id)) + return + } + do { + try update(loader.makeDefaultLibrary(device: device, bundle: bundle), forID: id) + } catch { + recordLoadingError( + .defaultLibraryCreationFailed( + libraryID: id, + bundlePath: bundle.bundleURL.path + ) + ) + } + case let .metallib(bundle, resource, subdirectory): + guard let device = renderInfo.device else { + recordLoadingError(.metalUnavailable(libraryID: id)) + return + } + guard let url = loader.resourceURL( + in: bundle, + resource: resource, + subdirectory: subdirectory + ) else { + recordLoadingError( + .resourceNotFound( + libraryID: id, + resource: resource, + subdirectory: subdirectory + ) + ) + return + } + do { + try update(loader.makeLibrary(device: device, url: url), forID: id) + } catch { + recordLoadingError( + .metallibCreationFailed( + libraryID: id, + resource: resource, + subdirectory: subdirectory + ) + ) + } + } + } + + func load(_ id: RenderShaderLibraryID, url: URL) { + registrationLock.lock() + defer { registrationLock.unlock() } + + guard let device = renderInfo.device else { + recordLoadingError(.metalUnavailable(libraryID: id)) + return + } + do { + try update(loader.makeLibrary(device: device, url: url), forID: id) + } catch { + recordLoadingError(.libraryCreationFailed(libraryID: id, url: url)) + } + } + + func removeLibraries(ownerID: String) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let ownedIDs = libraryOwners.compactMap { id, owner in + owner == ownerID ? id : nil + } + for id in ownedIDs { + librariesByID.removeValue(forKey: id) + libraryOwners.removeValue(forKey: id) + } + lock.unlock() + } + + func removeAll() { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + librariesByID.removeAll() + libraryOwners.removeAll() + lock.unlock() + } + + @discardableResult + fileprivate func registerLibraries( + ownerID: String, + _ registerBlock: (RenderShaderLibraryRegistry) -> Void + ) -> RenderShaderLibraryRegistrationReport { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let previousOwnerID = currentRegistrationOwnerID + let previousCollector = currentConflictCollector + let previousLoadingErrorCollector = currentLoadingErrorCollector + let conflictCollector = RenderExtensionConflictCollector() + let loadingErrorCollector = RenderShaderLibraryLoadingErrorCollector() + currentRegistrationOwnerID = ownerID + currentConflictCollector = conflictCollector + currentLoadingErrorCollector = loadingErrorCollector + lock.unlock() + + registerBlock(RenderShaderLibraryRegistry()) + + lock.lock() + currentRegistrationOwnerID = previousOwnerID + currentConflictCollector = previousCollector + currentLoadingErrorCollector = previousLoadingErrorCollector + lock.unlock() + return RenderShaderLibraryRegistrationReport( + conflicts: conflictCollector.conflicts, + loadingErrors: loadingErrorCollector.errors + ) + } + + func replaceLoaderForTesting( + _ replacement: any RenderShaderLibraryLoading + ) -> any RenderShaderLibraryLoading { + registrationLock.lock() + defer { registrationLock.unlock() } + + let previous = loader + loader = replacement + return previous + } + + private func recordLoadingError(_ error: RenderShaderLibraryLoadingError) { + lock.lock() + let collector = currentLoadingErrorCollector + lock.unlock() + if let collector { + collector.record(error) + } else { + Logger.logWarning(message: "[RenderExtension] \(error.description)") + } + } +} + +public struct RenderShaderLibraryRegistry { + public init() {} + + public func registerLibrary( + _ id: RenderShaderLibraryID, + library: MTLLibrary + ) { + registerLibrary(id, source: .library(library)) + } + + public func registerLibrary( + _ id: RenderShaderLibraryID, + source: RenderShaderLibrarySource + ) { + RenderShaderLibraryManager.shared.load(id, source: source) + } + + public func registerDefaultLibrary( + _ id: RenderShaderLibraryID, + bundle: Bundle + ) { + registerLibrary(id, source: .defaultLibrary(bundle: bundle)) + } + + public func registerLibrary( + _ id: RenderShaderLibraryID, + bundle: Bundle, + resource: String, + subdirectory: String? = nil + ) { + registerLibrary( + id, + source: .metallib( + bundle: bundle, + resource: resource, + subdirectory: subdirectory + ) + ) + } + + public func registerLibrary( + _ id: RenderShaderLibraryID, + url: URL + ) { + RenderShaderLibraryManager.shared.load(id, url: url) + } +} + +public struct RenderPipelineRegistry { + public init() {} + + public func registerRenderPipeline( + _ type: RenderPipelineType, + initBlock: RenderPipelineInitBlock + ) { + guard let pipeline = initBlock(), pipeline.success else { + PipelineManager.shared.recordRegistrationError( + .creationFailed(kind: .renderPipeline, pipelineID: type.rawValue) + ) + return + } + PipelineManager.shared.update(rendererPipeLine: pipeline, forType: type) + } + + public func registerRenderPipeline( + _ descriptor: RenderExtensionRenderPipelineDescriptor + ) { + let errors = validateRenderExtensionRenderPipeline(descriptor) + guard errors.isEmpty else { + for error in errors { + PipelineManager.shared.recordRegistrationError(error) + } + return + } + guard let pipeline = RenderExtensionPipelineCreator.shared.makeRenderPipeline(descriptor), + pipeline.success + else { + PipelineManager.shared.recordRegistrationError( + .creationFailed( + kind: .renderPipeline, + pipelineID: descriptor.id.rawValue + ) + ) + return + } + PipelineManager.shared.update(rendererPipeLine: pipeline, forType: descriptor.id) + } + + public func registerRenderPipeline( + _ type: RenderPipelineType, + vertexShader: String, + fragmentShader: String?, + vertexShaderLibrary: RenderShaderLibraryReference = .engine, + fragmentShaderLibrary: RenderShaderLibraryReference = .engine, + vertexDescriptor: MTLVertexDescriptor?, + colorFormats: [MTLPixelFormat], + depthFormat: MTLPixelFormat, + depthCompareFunction: MTLCompareFunction = .lessEqual, + depthEnabled: Bool = true, + reverseZCompatible: Bool = true, + blendMode: PipelineBlendMode = .none, + name: String + ) { + registerRenderPipeline( + RenderExtensionRenderPipelineDescriptor( + id: type, + vertexFunction: vertexShader, + fragmentFunction: fragmentShader, + vertexShaderLibrary: vertexShaderLibrary, + fragmentShaderLibrary: fragmentShaderLibrary, + vertexDescriptor: vertexDescriptor, + colorFormats: colorFormats, + depthFormat: depthFormat, + depthCompareFunction: depthCompareFunction, + depthEnabled: depthEnabled, + reverseZCompatible: reverseZCompatible, + blendMode: blendMode, + name: name + ) + ) + } + + public func registerModelSurfacePipeline( + _ type: RenderPipelineType, + fragmentShader: String, + fragmentShaderLibrary: RenderShaderLibraryReference = .engine, + colorFormats: [MTLPixelFormat]? = nil, + depthFormat: MTLPixelFormat? = nil, + depthCompareFunction: MTLCompareFunction = .lessEqual, + depthEnabled: Bool = false, + blendMode: PipelineBlendMode = .alphaPremultiplied, + name: String, + validation: RenderExtensionModelSurfacePipelineValidation = .disabled + ) { + registerRenderPipeline(type) { + CreatePipeline( + vertexShader: "vertexModelShader", + fragmentShader: fragmentShader, + fragmentShaderLibrary: fragmentShaderLibrary, + vertexDescriptor: createModelVertexDescriptor(), + colorFormats: colorFormats ?? [renderInfo.colorPipeline.working.sceneColor], + depthFormat: depthFormat ?? renderInfo.depthPixelFormat, + depthCompareFunction: depthCompareFunction, + depthEnabled: depthEnabled, + blendMode: blendMode, + name: name, + reflectionHandler: modelSurfacePipelineReflectionHandler( + validation: validation, + pipelineName: name, + fragmentShader: fragmentShader + ) + ) + } + } +} + +private func modelSurfacePipelineReflectionHandler( + validation: RenderExtensionModelSurfacePipelineValidation, + pipelineName: String, + fragmentShader: String +) -> ((MTLRenderPipelineReflection) -> Void)? { + switch validation { + case .disabled: + return nil + case let .warn(argumentLayoutID): + return { reflection in + let arguments = reflection.fragmentBindings.map { + RenderExtensionShaderArgument( + name: $0.name, + index: $0.index, + type: $0.type + ) + } + validateModelSurfaceExtensionPipelineArguments( + arguments, + argumentLayoutID: argumentLayoutID, + pipelineName: pipelineName, + fragmentShader: fragmentShader + ) + } + } +} + +@discardableResult +func validateModelSurfaceExtensionPipelineArguments( + _ arguments: [RenderExtensionShaderArgument], + argumentLayoutID: String?, + pipelineName: String, + fragmentShader: String +) -> Bool { + let expectedArgumentBufferIndex = RenderExtensionModelSurfaceArgument.argumentBufferIndex + let hasExpectedArgumentBuffer = arguments.contains { + $0.type == .buffer && $0.index == expectedArgumentBufferIndex + } + var isValid = true + + if !hasExpectedArgumentBuffer { + isValid = false + Logger.logWarning( + message: "[RenderExtension] Model-surface pipeline '\(pipelineName)' fragment '\(fragmentShader)' does not declare an extension argument buffer at [[buffer(\(expectedArgumentBufferIndex))]]" + ) + } + + let misplacedExtensionBuffers = arguments.filter { + $0.type == .buffer + && (11 ... 13).contains($0.index) + } + if !misplacedExtensionBuffers.isEmpty { + isValid = false + let slots = misplacedExtensionBuffers.map { "\($0.index)" }.joined(separator: ", ") + Logger.logWarning( + message: "[RenderExtension] Model-surface pipeline '\(pipelineName)' uses fragment buffer slot(s) \(slots) in the legacy extension range; use the argument buffer at [[buffer(\(expectedArgumentBufferIndex))]] instead" + ) + } + + let rawExtensionTextures = arguments.filter { + $0.type == .texture + && (10 ... 13).contains($0.index) + } + if !rawExtensionTextures.isEmpty { + isValid = false + let slots = rawExtensionTextures.map { "\($0.index)" }.joined(separator: ", ") + Logger.logWarning( + message: "[RenderExtension] Model-surface pipeline '\(pipelineName)' uses raw fragment texture slot(s) \(slots) in the legacy extension range; move them into the extension argument buffer" + ) + } + + if let argumentLayoutID, + RenderExtensionArgumentBufferRegistry.shared.descriptor(argumentLayoutID) == nil + { + isValid = false + Logger.logWarning( + message: "[RenderExtension] Model-surface pipeline '\(pipelineName)' references missing argument layout '\(argumentLayoutID)'" + ) + } + + return isValid +} + +public struct ComputePipelineType: Hashable, ExpressibleByStringLiteral, Sendable { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } +} + +public typealias ComputePipelineInitBlock = () -> ComputePipeline? + +public final class ComputePipelineManager: @unchecked Sendable { + public static let shared = ComputePipelineManager() + + private let lock = NSLock() + private let registrationLock = NSRecursiveLock() + private var computePipelinesByType: [ComputePipelineType: ComputePipeline] = [:] + private var computePipelineOwners: [ComputePipelineType: String] = [:] + private var currentRegistrationOwnerID: String? + private var currentConflictCollector: RenderExtensionConflictCollector? + private var currentErrorCollector: RenderExtensionPipelineErrorCollector? + private var currentRegistrationIDs: Set? + + private init() {} + + public func pipeline(for type: ComputePipelineType) -> ComputePipeline? { + lock.lock() + let pipeline = computePipelinesByType[type] + lock.unlock() + return pipeline + } + + func update(_ pipeline: ComputePipeline, forType type: ComputePipelineType) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + if currentRegistrationOwnerID != nil, + currentRegistrationIDs?.insert(type).inserted == false + { + currentErrorCollector?.record( + .duplicatePipelineID(kind: .computePipeline, pipelineID: type.rawValue) + ) + lock.unlock() + return + } + if let ownerID = currentRegistrationOwnerID, + computePipelinesByType[type] != nil, + computePipelineOwners[type] != ownerID + { + currentConflictCollector?.record( + RenderExtensionArtifactConflict( + kind: .computePipeline, + artifactID: type.rawValue, + requestedOwnerID: ownerID, + existingOwnerID: computePipelineOwners[type] + ) + ) + lock.unlock() + return + } + computePipelinesByType[type] = pipeline + if let currentRegistrationOwnerID { + computePipelineOwners[type] = currentRegistrationOwnerID + } else { + computePipelineOwners.removeValue(forKey: type) + } + lock.unlock() + } + + func removePipelines(ownerID: String) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let ownedTypes = computePipelineOwners.compactMap { type, owner in + owner == ownerID ? type : nil + } + for type in ownedTypes { + computePipelinesByType.removeValue(forKey: type) + computePipelineOwners.removeValue(forKey: type) + } + lock.unlock() + } + + func removeAll() { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + computePipelinesByType.removeAll() + computePipelineOwners.removeAll() + lock.unlock() + } + + @discardableResult + func registerPipelines( + ownerID: String, + _ registerBlock: (ComputePipelineRegistry) -> Void + ) -> RenderExtensionPipelineRegistrationReport { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let previousOwnerID = currentRegistrationOwnerID + let previousCollector = currentConflictCollector + let previousErrorCollector = currentErrorCollector + let previousRegistrationIDs = currentRegistrationIDs + let conflictCollector = RenderExtensionConflictCollector() + let errorCollector = RenderExtensionPipelineErrorCollector() + currentRegistrationOwnerID = ownerID + currentConflictCollector = conflictCollector + currentErrorCollector = errorCollector + currentRegistrationIDs = [] + lock.unlock() + + registerBlock(ComputePipelineRegistry()) + + lock.lock() + currentRegistrationOwnerID = previousOwnerID + currentConflictCollector = previousCollector + currentErrorCollector = previousErrorCollector + currentRegistrationIDs = previousRegistrationIDs + lock.unlock() + return RenderExtensionPipelineRegistrationReport( + conflicts: conflictCollector.conflicts, + errors: errorCollector.errors + ) + } + + func recordRegistrationError(_ error: RenderExtensionPipelineError) { + lock.lock() + let collector = currentErrorCollector + lock.unlock() + if let collector { + collector.record(error) + } else { + Logger.logWarning(message: "[RenderExtension] \(error.description)") + } + } +} + +public struct ComputePipelineRegistry { + public init() {} + + public func registerComputePipeline( + _ type: ComputePipelineType, + initBlock: ComputePipelineInitBlock + ) { + guard let pipeline = initBlock(), pipeline.success else { + ComputePipelineManager.shared.recordRegistrationError( + .creationFailed(kind: .computePipeline, pipelineID: type.rawValue) + ) + return + } + ComputePipelineManager.shared.update(pipeline, forType: type) + } + + public func registerComputePipeline( + _ descriptor: RenderExtensionComputePipelineDescriptor + ) { + let validation = validateRenderExtensionComputePipeline(descriptor) + guard validation.errors.isEmpty, let library = validation.library else { + for error in validation.errors { + ComputePipelineManager.shared.recordRegistrationError(error) + } + return + } + guard let pipeline = RenderExtensionPipelineCreator.shared.makeComputePipeline( + descriptor, + library: library + ), pipeline.success else { + ComputePipelineManager.shared.recordRegistrationError( + .creationFailed( + kind: .computePipeline, + pipelineID: descriptor.id.rawValue + ) + ) + return + } + ComputePipelineManager.shared.update(pipeline, forType: descriptor.id) + } + + public func registerComputePipeline( + _ type: ComputePipelineType, + functionName: String, + shaderLibrary: RenderShaderLibraryReference = .engine, + pipelineName: String + ) { + registerComputePipeline( + RenderExtensionComputePipelineDescriptor( + id: type, + function: functionName, + shaderLibrary: shaderLibrary, + name: pipelineName + ) + ) + } +} + +public struct ComputePipelineAccess { + public init() {} + + public func pipeline(_ type: ComputePipelineType) -> ComputePipeline? { + ComputePipelineManager.shared.pipeline(for: type) + } +} + +public struct RenderTextureResourceID: RawRepresentable, Hashable, ExpressibleByStringLiteral, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } +} + +public struct RenderBufferResourceID: RawRepresentable, Hashable, ExpressibleByStringLiteral, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } +} + +public enum RenderExtensionResourceLifetime: Equatable, Sendable { + /// The resource remains allocated while its owning extension is registered. + case persistent + + /// The resource is eligible for render-graph lifetime planning and backing-store reuse. + case transient +} + +public enum RenderExtensionResourceSize: Equatable, Sendable { + case viewportScale(Float) + case fixed(width: Int, height: Int) +} + +public enum RenderExtensionResourceValidationError: Error, Equatable, Sendable, CustomStringConvertible { + case emptyID + case invalidViewportScale(id: String, scale: Float) + case invalidTextureDimensions(id: String, width: Int, height: Int) + case emptyTextureUsage(id: String) + case invalidMipMapLevels(id: String, count: Int) + case invalidSampleCount(id: String, count: Int) + case multisampledTextureHasMipmaps(id: String) + case invalidBufferLength(id: String, length: Int) + + public var description: String { + switch self { + case .emptyID: + return "Render extension resource IDs cannot be empty" + case let .invalidViewportScale(id, scale): + return "Texture resource '\(id)' has invalid viewport scale \(scale)" + case let .invalidTextureDimensions(id, width, height): + return "Texture resource '\(id)' has invalid dimensions \(width)x\(height)" + case let .emptyTextureUsage(id): + return "Texture resource '\(id)' must declare at least one usage" + case let .invalidMipMapLevels(id, count): + return "Texture resource '\(id)' has invalid mipmap level count \(count)" + case let .invalidSampleCount(id, count): + return "Texture resource '\(id)' has invalid sample count \(count)" + case let .multisampledTextureHasMipmaps(id): + return "Multisampled texture resource '\(id)' cannot declare mipmaps" + case let .invalidBufferLength(id, length): + return "Buffer resource '\(id)' has invalid length \(length)" + } + } +} + +public enum RenderExtensionResourceState: String, Equatable, Sendable { + case declared + case allocated + case invalidated + case released +} + +public enum RenderExtensionResourceAllocationFailure: Equatable, Sendable { + case textureCreationFailed(width: Int, height: Int) + case bufferCreationFailed(length: Int) +} + +public struct RenderExtensionResourceAllocationError: Error, Equatable, Sendable, CustomStringConvertible { + public let kind: RenderExtensionArtifactKind + public let resourceID: String + public let ownerID: String? + public let failure: RenderExtensionResourceAllocationFailure + + public init( + kind: RenderExtensionArtifactKind, + resourceID: String, + ownerID: String?, + failure: RenderExtensionResourceAllocationFailure + ) { + self.kind = kind + self.resourceID = resourceID + self.ownerID = ownerID + self.failure = failure + } + + public var description: String { + let owner = ownerID.map { " for extension '\($0)'" } ?? "" + switch failure { + case let .textureCreationFailed(width, height): + return "Failed to allocate texture resource '\(resourceID)'\(owner) at \(width)x\(height)" + case let .bufferCreationFailed(length): + return "Failed to allocate buffer resource '\(resourceID)'\(owner) with length \(length)" + } + } +} + +public struct RenderExtensionTextureDescriptor: Sendable { + public let id: RenderTextureResourceID + public let label: String + public let size: RenderExtensionResourceSize + public let pixelFormat: MTLPixelFormat + public let usage: MTLTextureUsage + public let storageMode: MTLStorageMode + public let mipMapLevels: Int + public let sampleCount: Int + public let lifetime: RenderExtensionResourceLifetime + + public init( + id: RenderTextureResourceID, + label: String? = nil, + size: RenderExtensionResourceSize, + pixelFormat: MTLPixelFormat, + usage: MTLTextureUsage, + storageMode: MTLStorageMode = .private, + mipMapLevels: Int = 1, + sampleCount: Int = 1, + lifetime: RenderExtensionResourceLifetime = .persistent + ) { + self.id = id + self.label = label ?? id.rawValue + self.size = size + self.pixelFormat = pixelFormat + self.usage = usage + self.storageMode = storageMode + self.mipMapLevels = mipMapLevels + self.sampleCount = sampleCount + self.lifetime = lifetime + } + + public init( + id: String, + label: String? = nil, + size: RenderExtensionResourceSize, + pixelFormat: MTLPixelFormat, + usage: MTLTextureUsage, + storageMode: MTLStorageMode = .private, + mipMapLevels: Int = 1, + sampleCount: Int = 1, + lifetime: RenderExtensionResourceLifetime = .persistent + ) { + self.init( + id: RenderTextureResourceID(id), + label: label, + size: size, + pixelFormat: pixelFormat, + usage: usage, + storageMode: storageMode, + mipMapLevels: mipMapLevels, + sampleCount: sampleCount, + lifetime: lifetime + ) + } + + public func validationErrors() -> [RenderExtensionResourceValidationError] { + var errors: [RenderExtensionResourceValidationError] = [] + let resourceID = id.rawValue + + if resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyID) + } + switch size { + case let .viewportScale(scale): + if !scale.isFinite || scale <= 0 { + errors.append(.invalidViewportScale(id: resourceID, scale: scale)) + } + case let .fixed(width, height): + if width <= 0 || height <= 0 { + errors.append(.invalidTextureDimensions(id: resourceID, width: width, height: height)) + } + } + if usage.isEmpty { + errors.append(.emptyTextureUsage(id: resourceID)) + } + if mipMapLevels < 1 { + errors.append(.invalidMipMapLevels(id: resourceID, count: mipMapLevels)) + } + if sampleCount < 1 { + errors.append(.invalidSampleCount(id: resourceID, count: sampleCount)) + } + if sampleCount > 1, mipMapLevels > 1 { + errors.append(.multisampledTextureHasMipmaps(id: resourceID)) + } + return errors + } + + public func validate() throws { + if let error = validationErrors().first { + throw error + } + } +} + +public struct RenderExtensionBufferDescriptor: Sendable { + public let id: RenderBufferResourceID + public let label: String + public let length: Int + public let options: MTLResourceOptions + public let lifetime: RenderExtensionResourceLifetime + + public init( + id: RenderBufferResourceID, + label: String? = nil, + length: Int, + options: MTLResourceOptions = .storageModeShared, + lifetime: RenderExtensionResourceLifetime = .persistent + ) { + self.id = id + self.label = label ?? id.rawValue + self.length = length + self.options = options + self.lifetime = lifetime + } + + public init( + id: String, + label: String? = nil, + length: Int, + options: MTLResourceOptions = .storageModeShared, + lifetime: RenderExtensionResourceLifetime = .persistent + ) { + self.init( + id: RenderBufferResourceID(id), + label: label, + length: length, + options: options, + lifetime: lifetime + ) + } + + public func validationErrors() -> [RenderExtensionResourceValidationError] { + var errors: [RenderExtensionResourceValidationError] = [] + let resourceID = id.rawValue + + if resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errors.append(.emptyID) + } + if length <= 0 { + errors.append(.invalidBufferLength(id: resourceID, length: length)) + } + return errors + } + + public func validate() throws { + if let error = validationErrors().first { + throw error + } + } +} + +public struct RenderResourceAccess { + private let allowedTextureIDs: Set? + private let allowedBufferIDs: Set? + + public init() { + allowedTextureIDs = nil + allowedBufferIDs = nil + } + + init(resourceUsages: [RenderGraphResourceUsage]) { + allowedTextureIDs = Set(resourceUsages.compactMap { usage in + if case let .texture(id, _) = usage { return id } + return nil + }) + allowedBufferIDs = Set(resourceUsages.compactMap { usage in + if case let .buffer(id, _) = usage { return id } + return nil + }) + } + + public func texture(_ id: RenderTextureResourceID) -> MTLTexture? { + guard allowedTextureIDs?.contains(id) != false else { return nil } + return RenderResourceRegistry.shared.texture(id) + } + + public func texture(_ id: String) -> MTLTexture? { + texture(RenderTextureResourceID(id)) + } + + public func buffer(_ id: RenderBufferResourceID) -> MTLBuffer? { + guard allowedBufferIDs?.contains(id) != false else { return nil } + return RenderResourceRegistry.shared.buffer(id) + } + + public func buffer(_ id: String) -> MTLBuffer? { + buffer(RenderBufferResourceID(id)) + } + + public func textureState(_ id: RenderTextureResourceID) -> RenderExtensionResourceState? { + guard allowedTextureIDs?.contains(id) != false else { return nil } + return RenderResourceRegistry.shared.textureState(id) + } + + public func textureState(_ id: String) -> RenderExtensionResourceState? { + textureState(RenderTextureResourceID(id)) + } + + public func bufferState(_ id: RenderBufferResourceID) -> RenderExtensionResourceState? { + guard allowedBufferIDs?.contains(id) != false else { return nil } + return RenderResourceRegistry.shared.bufferState(id) + } + + public func bufferState(_ id: String) -> RenderExtensionResourceState? { + bufferState(RenderBufferResourceID(id)) + } +} + +public struct RenderExtensionArgumentTexture { + public let id: Int + public let textureType: MTLTextureType + public let access: MTLBindingAccess + + public init( + id: Int, + textureType: MTLTextureType = .type2D, + access: MTLBindingAccess = .readOnly + ) { + self.id = id + self.textureType = textureType + self.access = access + } +} + +public struct RenderExtensionArgumentSampler { + public let id: Int + + public init(id: Int) { + self.id = id + } +} + +public struct RenderExtensionArgumentBuffer { + public let id: Int + public let access: MTLBindingAccess + + public init( + id: Int, + access: MTLBindingAccess = .readOnly + ) { + self.id = id + self.access = access + } +} + +public struct RenderExtensionArgumentBufferDescriptor { + public let id: String + public let textures: [RenderExtensionArgumentTexture] + public let samplers: [RenderExtensionArgumentSampler] + public let buffers: [RenderExtensionArgumentBuffer] + + public init( + id: String, + textures: [RenderExtensionArgumentTexture] = [], + samplers: [RenderExtensionArgumentSampler] = [], + buffers: [RenderExtensionArgumentBuffer] = [] + ) { + self.id = id + self.textures = textures + self.samplers = samplers + self.buffers = buffers + } +} + +public final class RenderExtensionArgumentBufferRegistry: @unchecked Sendable { + public static let shared = RenderExtensionArgumentBufferRegistry() + + private let lock = NSLock() + private let registrationLock = NSRecursiveLock() + private var descriptorsByID: [String: RenderExtensionArgumentBufferDescriptor] = [:] + private var descriptorOwners: [String: String] = [:] + private var currentRegistrationOwnerID: String? + private var currentConflictCollector: RenderExtensionConflictCollector? + + private init() {} + + public func registerArgumentBuffer(_ descriptor: RenderExtensionArgumentBufferDescriptor) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + if let ownerID = currentRegistrationOwnerID, + descriptorsByID[descriptor.id] != nil, + descriptorOwners[descriptor.id] != ownerID + { + currentConflictCollector?.record( + RenderExtensionArtifactConflict( + kind: .argumentBuffer, + artifactID: descriptor.id, + requestedOwnerID: ownerID, + existingOwnerID: descriptorOwners[descriptor.id] + ) + ) + lock.unlock() + return + } + descriptorsByID[descriptor.id] = descriptor + if let currentRegistrationOwnerID { + descriptorOwners[descriptor.id] = currentRegistrationOwnerID + } else { + descriptorOwners.removeValue(forKey: descriptor.id) + } + lock.unlock() + } + + public func descriptor(_ id: String) -> RenderExtensionArgumentBufferDescriptor? { + lock.lock() + let descriptor = descriptorsByID[id] + lock.unlock() + return descriptor + } + + public func registeredIDs() -> [String] { + lock.lock() + let ids = Array(descriptorsByID.keys) + lock.unlock() + return ids + } + + public func removeAll() { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + descriptorsByID.removeAll() + descriptorOwners.removeAll() + lock.unlock() + } + + func removeArgumentBuffers(ownerID: String) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let ownedIDs = descriptorOwners.compactMap { id, owner in + owner == ownerID ? id : nil + } + for id in ownedIDs { + descriptorsByID.removeValue(forKey: id) + descriptorOwners.removeValue(forKey: id) + } + lock.unlock() + } + + @discardableResult + func registerArgumentBuffers( + ownerID: String, + _ registerBlock: (RenderExtensionArgumentBufferRegistry) -> Void + ) -> [RenderExtensionArtifactConflict] { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + let previousOwnerID = currentRegistrationOwnerID + let previousCollector = currentConflictCollector + let collector = RenderExtensionConflictCollector() + currentRegistrationOwnerID = ownerID + currentConflictCollector = collector + lock.unlock() + + registerBlock(self) + + lock.lock() + currentRegistrationOwnerID = previousOwnerID + currentConflictCollector = previousCollector + lock.unlock() + return collector.conflicts + } +} + +protocol RenderExtensionResourceAllocating: AnyObject { + func makeTexture( + device: MTLDevice, + descriptor: RenderExtensionTextureDescriptor, + width: Int, + height: Int + ) -> MTLTexture? + + func makeBuffer( + device: MTLDevice, + descriptor: RenderExtensionBufferDescriptor + ) -> MTLBuffer? +} + +private final class DefaultRenderExtensionResourceAllocator: RenderExtensionResourceAllocating { + func makeTexture( + device: MTLDevice, + descriptor: RenderExtensionTextureDescriptor, + width: Int, + height: Int + ) -> MTLTexture? { + createTexture( + device: device, + label: descriptor.label, + pixelFormat: descriptor.pixelFormat, + width: width, + height: height, + usage: descriptor.usage, + storageMode: descriptor.storageMode, + mipMapLevels: descriptor.mipMapLevels, + sampleCount: descriptor.sampleCount + ) + } + + func makeBuffer( + device: MTLDevice, + descriptor: RenderExtensionBufferDescriptor + ) -> MTLBuffer? { + createEmptyBuffer( + device: device, + length: descriptor.length, + options: descriptor.options, + label: descriptor.label + ) + } +} + +private final class RenderResourceDeclarationCollector { + let ownerID: String + var textureDescriptors: [RenderExtensionTextureDescriptor] = [] + var bufferDescriptors: [RenderExtensionBufferDescriptor] = [] + + init(ownerID: String) { + self.ownerID = ownerID + } +} + +private struct RenderResourceRegistrationReport { + let conflicts: [RenderExtensionArtifactConflict] + let validationErrors: [RenderExtensionResourceValidationError] + let committed: Bool + + var succeeded: Bool { + conflicts.isEmpty && validationErrors.isEmpty + } +} + +public final class RenderResourceRegistry: @unchecked Sendable { + public static let shared = RenderResourceRegistry() + + private let lock = NSLock() + private let registrationLock = NSRecursiveLock() + private var textureDescriptors: [RenderTextureResourceID: RenderExtensionTextureDescriptor] = [:] + private var textures: [RenderTextureResourceID: MTLTexture] = [:] + private var textureOwners: [RenderTextureResourceID: String] = [:] + private var textureStates: [RenderTextureResourceID: RenderExtensionResourceState] = [:] + private var textureAllocationErrors: [RenderTextureResourceID: RenderExtensionResourceAllocationError] = [:] + private var bufferDescriptors: [RenderBufferResourceID: RenderExtensionBufferDescriptor] = [:] + private var buffers: [RenderBufferResourceID: MTLBuffer] = [:] + private var bufferOwners: [RenderBufferResourceID: String] = [:] + private var bufferStates: [RenderBufferResourceID: RenderExtensionResourceState] = [:] + private var bufferAllocationErrors: [RenderBufferResourceID: RenderExtensionResourceAllocationError] = [:] + private var currentDeclarationCollector: RenderResourceDeclarationCollector? + private var allocator: any RenderExtensionResourceAllocating = DefaultRenderExtensionResourceAllocator() + + private init() {} + + public func registerTexture(_ descriptor: RenderExtensionTextureDescriptor) { + registrationLock.lock() + defer { registrationLock.unlock() } + + if let currentDeclarationCollector { + currentDeclarationCollector.textureDescriptors.append(descriptor) + return + } + + let validationErrors = descriptor.validationErrors() + guard validationErrors.isEmpty else { + logValidationErrors(validationErrors) + return + } + + lock.lock() + textureDescriptors[descriptor.id] = descriptor + textures.removeValue(forKey: descriptor.id) + textureOwners.removeValue(forKey: descriptor.id) + textureStates[descriptor.id] = .declared + textureAllocationErrors.removeValue(forKey: descriptor.id) + lock.unlock() + + if renderInfo.device != nil, hasValidViewport { + allocateTexture(descriptor) + } + } + + public func texture(_ id: String) -> MTLTexture? { + texture(RenderTextureResourceID(id)) + } + + public func texture(_ id: RenderTextureResourceID) -> MTLTexture? { + lock.lock() + let texture = textureStates[id] == .allocated ? textures[id] : nil + lock.unlock() + return texture + } + + public func textureState(_ id: RenderTextureResourceID) -> RenderExtensionResourceState? { + lock.lock() + let state = textureStates[id] + lock.unlock() + return state + } + + public func textureState(_ id: String) -> RenderExtensionResourceState? { + textureState(RenderTextureResourceID(id)) + } + + public func textureAllocationError( + _ id: RenderTextureResourceID + ) -> RenderExtensionResourceAllocationError? { + lock.lock() + let error = textureAllocationErrors[id] + lock.unlock() + return error + } + + public func registerBuffer(_ descriptor: RenderExtensionBufferDescriptor) { + registrationLock.lock() + defer { registrationLock.unlock() } + + if let currentDeclarationCollector { + currentDeclarationCollector.bufferDescriptors.append(descriptor) + return + } + + let validationErrors = descriptor.validationErrors() + guard validationErrors.isEmpty else { + logValidationErrors(validationErrors) + return + } + + lock.lock() + bufferDescriptors[descriptor.id] = descriptor + buffers.removeValue(forKey: descriptor.id) + bufferOwners.removeValue(forKey: descriptor.id) + bufferStates[descriptor.id] = .declared + bufferAllocationErrors.removeValue(forKey: descriptor.id) + lock.unlock() + + if renderInfo.device != nil { + allocateBuffer(descriptor) + } + } + + public func buffer(_ id: String) -> MTLBuffer? { + buffer(RenderBufferResourceID(id)) + } + + public func buffer(_ id: RenderBufferResourceID) -> MTLBuffer? { + lock.lock() + let buffer = bufferStates[id] == .allocated ? buffers[id] : nil + lock.unlock() + return buffer + } + + public func bufferState(_ id: RenderBufferResourceID) -> RenderExtensionResourceState? { + lock.lock() + let state = bufferStates[id] + lock.unlock() + return state + } + + public func bufferState(_ id: String) -> RenderExtensionResourceState? { + bufferState(RenderBufferResourceID(id)) + } + + public func bufferAllocationError( + _ id: RenderBufferResourceID + ) -> RenderExtensionResourceAllocationError? { + lock.lock() + let error = bufferAllocationErrors[id] + lock.unlock() + return error + } + + func textureDeclaration( + _ id: RenderTextureResourceID + ) -> (descriptor: RenderExtensionTextureDescriptor, ownerID: String?)? { + lock.lock() + guard let descriptor = textureDescriptors[id] else { + lock.unlock() + return nil + } + let ownerID = textureOwners[id] + lock.unlock() + return (descriptor, ownerID) + } + + func bufferDeclaration( + _ id: RenderBufferResourceID + ) -> (descriptor: RenderExtensionBufferDescriptor, ownerID: String?)? { + lock.lock() + guard let descriptor = bufferDescriptors[id] else { + lock.unlock() + return nil + } + let ownerID = bufferOwners[id] + lock.unlock() + return (descriptor, ownerID) + } + + func declarationSnapshot() -> [RenderGraphResourceDeclarationSnapshot] { + lock.lock() + let textures = textureDescriptors.map { id, descriptor in + RenderGraphResourceDeclarationSnapshot( + kind: .texture, + resourceID: id.rawValue, + ownerID: textureOwners[id], + lifetime: descriptor.lifetime + ) + } + let buffers = bufferDescriptors.map { id, descriptor in + RenderGraphResourceDeclarationSnapshot( + kind: .buffer, + resourceID: id.rawValue, + ownerID: bufferOwners[id], + lifetime: descriptor.lifetime + ) + } + lock.unlock() + return (textures + buffers).sorted { lhs, rhs in + if lhs.kind.rawValue != rhs.kind.rawValue { + return lhs.kind.rawValue < rhs.kind.rawValue + } + return lhs.resourceID < rhs.resourceID + } + } + + public func removeAll() { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + for id in textureDescriptors.keys { + textureStates[id] = .released + } + for id in bufferDescriptors.keys { + bufferStates[id] = .released + } + textureDescriptors.removeAll() + textures.removeAll() + textureOwners.removeAll() + textureAllocationErrors.removeAll() + bufferDescriptors.removeAll() + buffers.removeAll() + bufferOwners.removeAll() + bufferAllocationErrors.removeAll() + lock.unlock() + } + + func removeResources(ownerID: String) { + registrationLock.lock() + defer { registrationLock.unlock() } + + lock.lock() + removeResourceStorage(ownerID: ownerID) + lock.unlock() + } + + @discardableResult + fileprivate func registerResources( + ownerID: String, + commitIfValid: Bool = true, + _ registerBlock: (RenderResourceRegistry) -> Void + ) -> RenderResourceRegistrationReport { + registrationLock.lock() + defer { registrationLock.unlock() } + + let previousCollector = currentDeclarationCollector + let collector = RenderResourceDeclarationCollector(ownerID: ownerID) + currentDeclarationCollector = collector + + registerBlock(self) + + currentDeclarationCollector = previousCollector + return evaluateAndCommit(collector, commitIfValid: commitIfValid) + } + + func recreateResources() { + registrationLock.lock() + defer { registrationLock.unlock() } + + guard renderInfo.device != nil else { return } + + lock.lock() + let textureDescriptors = hasValidViewport ? Array(textureDescriptors.values) : [] + let bufferDescriptors = Array(bufferDescriptors.values) + lock.unlock() + + for descriptor in textureDescriptors { + allocateTextureIfNeeded(descriptor) + } + for descriptor in bufferDescriptors { + allocateBufferIfNeeded(descriptor) + } + } + + func replaceAllocatorForTesting( + _ replacement: any RenderExtensionResourceAllocating + ) -> any RenderExtensionResourceAllocating { + registrationLock.lock() + defer { registrationLock.unlock() } + + let previous = allocator + allocator = replacement + return previous + } + + func allocationErrors(ownerID: String) -> [RenderExtensionResourceAllocationError] { + lock.lock() + let errors = textureAllocationErrors.values.filter { $0.ownerID == ownerID } + + bufferAllocationErrors.values.filter { $0.ownerID == ownerID } + lock.unlock() + return errors.sorted { + if $0.kind.rawValue == $1.kind.rawValue { + return $0.resourceID < $1.resourceID + } + return $0.kind.rawValue < $1.kind.rawValue + } + } + + private func evaluateAndCommit( + _ collector: RenderResourceDeclarationCollector, + commitIfValid: Bool + ) -> RenderResourceRegistrationReport { + var validationErrors: [RenderExtensionResourceValidationError] = [] + for descriptor in collector.textureDescriptors { + appendUnique(descriptor.validationErrors(), to: &validationErrors) + } + for descriptor in collector.bufferDescriptors { + appendUnique(descriptor.validationErrors(), to: &validationErrors) + } + + lock.lock() + let existingTextureDescriptors = textureDescriptors + let existingTextureOwners = textureOwners + let existingBufferDescriptors = bufferDescriptors + let existingBufferOwners = bufferOwners + lock.unlock() + + var conflicts: [RenderExtensionArtifactConflict] = [] + var seenTextureIDs: Set = [] + for descriptor in collector.textureDescriptors { + if !seenTextureIDs.insert(descriptor.id).inserted { + appendUnique( + RenderExtensionArtifactConflict( + kind: .texture, + artifactID: descriptor.id.rawValue, + requestedOwnerID: collector.ownerID, + existingOwnerID: collector.ownerID + ), + to: &conflicts + ) + } else if existingTextureDescriptors[descriptor.id] != nil, + existingTextureOwners[descriptor.id] != collector.ownerID + { + appendUnique( + RenderExtensionArtifactConflict( + kind: .texture, + artifactID: descriptor.id.rawValue, + requestedOwnerID: collector.ownerID, + existingOwnerID: existingTextureOwners[descriptor.id] + ), + to: &conflicts + ) + } + } + + var seenBufferIDs: Set = [] + for descriptor in collector.bufferDescriptors { + if !seenBufferIDs.insert(descriptor.id).inserted { + appendUnique( + RenderExtensionArtifactConflict( + kind: .buffer, + artifactID: descriptor.id.rawValue, + requestedOwnerID: collector.ownerID, + existingOwnerID: collector.ownerID + ), + to: &conflicts + ) + } else if existingBufferDescriptors[descriptor.id] != nil, + existingBufferOwners[descriptor.id] != collector.ownerID + { + appendUnique( + RenderExtensionArtifactConflict( + kind: .buffer, + artifactID: descriptor.id.rawValue, + requestedOwnerID: collector.ownerID, + existingOwnerID: existingBufferOwners[descriptor.id] + ), + to: &conflicts + ) + } + } + + guard validationErrors.isEmpty, conflicts.isEmpty, commitIfValid else { + return RenderResourceRegistrationReport( + conflicts: conflicts, + validationErrors: validationErrors, + committed: false + ) + } + + lock.lock() + removeResourceStorage(ownerID: collector.ownerID) + for descriptor in collector.textureDescriptors { + textureDescriptors[descriptor.id] = descriptor + textureOwners[descriptor.id] = collector.ownerID + textureStates[descriptor.id] = .declared + textureAllocationErrors.removeValue(forKey: descriptor.id) + } + for descriptor in collector.bufferDescriptors { + bufferDescriptors[descriptor.id] = descriptor + bufferOwners[descriptor.id] = collector.ownerID + bufferStates[descriptor.id] = .declared + bufferAllocationErrors.removeValue(forKey: descriptor.id) + } + lock.unlock() + + if renderInfo.device != nil, hasValidViewport { + for descriptor in collector.textureDescriptors { + allocateTexture(descriptor) + } + } + if renderInfo.device != nil { + for descriptor in collector.bufferDescriptors { + allocateBuffer(descriptor) + } + } + + return RenderResourceRegistrationReport( + conflicts: [], + validationErrors: [], + committed: true + ) + } + + private func removeResourceStorage(ownerID: String) { + let ownedTextureIDs = textureOwners.compactMap { id, owner in + owner == ownerID ? id : nil + } + for textureID in ownedTextureIDs { + textureDescriptors.removeValue(forKey: textureID) + textures.removeValue(forKey: textureID) + textureOwners.removeValue(forKey: textureID) + textureStates[textureID] = .released + textureAllocationErrors.removeValue(forKey: textureID) + } + let ownedBufferIDs = bufferOwners.compactMap { id, owner in + owner == ownerID ? id : nil + } + for bufferID in ownedBufferIDs { + bufferDescriptors.removeValue(forKey: bufferID) + buffers.removeValue(forKey: bufferID) + bufferOwners.removeValue(forKey: bufferID) + bufferStates[bufferID] = .released + bufferAllocationErrors.removeValue(forKey: bufferID) + } + } + + private func appendUnique(_ values: [T], to destination: inout [T]) { + for value in values { + appendUnique(value, to: &destination) + } + } + + private func appendUnique(_ value: T, to destination: inout [T]) { + if !destination.contains(value) { + destination.append(value) + } + } + + private func allocateTextureIfNeeded(_ descriptor: RenderExtensionTextureDescriptor) { + let size = resolvedSize(for: descriptor.size) + lock.lock() + let isCurrent = textureStates[descriptor.id] == .allocated + && textures[descriptor.id].map { + $0.width == size.width + && $0.height == size.height + && $0.pixelFormat == descriptor.pixelFormat + && $0.usage == descriptor.usage + && $0.storageMode == descriptor.storageMode + && $0.mipmapLevelCount == descriptor.mipMapLevels + && $0.sampleCount == descriptor.sampleCount + && $0.label == descriptor.label + } == true + lock.unlock() + + if !isCurrent { + allocateTexture(descriptor, resolvedSize: size) + } + } + + private func allocateTexture( + _ descriptor: RenderExtensionTextureDescriptor, + resolvedSize: (width: Int, height: Int)? = nil + ) { + guard let device = renderInfo.device, hasValidViewport else { return } + + let size = resolvedSize ?? self.resolvedSize(for: descriptor.size) + lock.lock() + let ownerID = textureOwners[descriptor.id] + textureStates[descriptor.id] = .invalidated + textureAllocationErrors.removeValue(forKey: descriptor.id) + lock.unlock() + + guard let texture = allocator.makeTexture( + device: device, + descriptor: descriptor, + width: size.width, + height: size.height + ) else { + let error = RenderExtensionResourceAllocationError( + kind: .texture, + resourceID: descriptor.id.rawValue, + ownerID: ownerID, + failure: .textureCreationFailed(width: size.width, height: size.height) + ) + lock.lock() + textures.removeValue(forKey: descriptor.id) + textureStates[descriptor.id] = .invalidated + textureAllocationErrors[descriptor.id] = error + lock.unlock() + Logger.logWarning(message: "[RenderExtension] \(error.description)") + return + } + + lock.lock() + textures[descriptor.id] = texture + textureStates[descriptor.id] = .allocated + textureAllocationErrors.removeValue(forKey: descriptor.id) + lock.unlock() + } + + private func allocateBufferIfNeeded(_ descriptor: RenderExtensionBufferDescriptor) { + lock.lock() + let isCurrent = bufferStates[descriptor.id] == .allocated + && buffers[descriptor.id] != nil + lock.unlock() + + if !isCurrent { + allocateBuffer(descriptor) + } + } + + private func allocateBuffer(_ descriptor: RenderExtensionBufferDescriptor) { + guard let device = renderInfo.device else { return } + + lock.lock() + let ownerID = bufferOwners[descriptor.id] + bufferStates[descriptor.id] = .invalidated + bufferAllocationErrors.removeValue(forKey: descriptor.id) + lock.unlock() + + guard let buffer = allocator.makeBuffer(device: device, descriptor: descriptor) else { + let error = RenderExtensionResourceAllocationError( + kind: .buffer, + resourceID: descriptor.id.rawValue, + ownerID: ownerID, + failure: .bufferCreationFailed(length: descriptor.length) + ) + lock.lock() + buffers.removeValue(forKey: descriptor.id) + bufferStates[descriptor.id] = .invalidated + bufferAllocationErrors[descriptor.id] = error + lock.unlock() + Logger.logWarning(message: "[RenderExtension] \(error.description)") + return + } + + lock.lock() + buffers[descriptor.id] = buffer + bufferStates[descriptor.id] = .allocated + bufferAllocationErrors.removeValue(forKey: descriptor.id) + lock.unlock() + } + + private func resolvedSize(for size: RenderExtensionResourceSize) -> (width: Int, height: Int) { + switch size { + case let .viewportScale(scale): + return ( + width: max(1, Int((renderInfo.viewPort?.x ?? 0) * scale)), + height: max(1, Int((renderInfo.viewPort?.y ?? 0) * scale)) + ) + case let .fixed(width, height): + return (width: width, height: height) + } + } + + private var hasValidViewport: Bool { + guard let viewport = renderInfo.viewPort else { return false } + return viewport.x.isFinite && viewport.y.isFinite && viewport.x > 0 && viewport.y > 0 + } + + private func logValidationErrors(_ errors: [RenderExtensionResourceValidationError]) { + for error in errors { + Logger.logWarning(message: "[RenderExtension] \(error.description)") + } + } +} + +public final class RenderExtensionRegistry: @unchecked Sendable { + public static let shared = RenderExtensionRegistry() + + private let lock = NSLock() + private let lifecycleLock = NSRecursiveLock() + private var extensionsByID: [String: any RenderExtension] = [:] + private var extensionOrder: [String] = [] + private var registrationConflictsByExtensionID: [String: [RenderExtensionArtifactConflict]] = [:] + private var shaderLibraryErrorsByExtensionID: [String: [RenderShaderLibraryLoadingError]] = [:] + private var pipelineErrorsByExtensionID: [String: [RenderExtensionPipelineError]] = [:] + private var resourceValidationErrorsByExtensionID: [String: [RenderExtensionResourceValidationError]] = [:] + private var graphValidationErrorsByExtensionID: [String: [RenderGraphError]] = [:] + + private init() {} + + func performPluginLifecycle(_ body: () -> T) -> T { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + return body() + } + + func registeredExtension(id: String) -> (any RenderExtension)? { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let renderExtension = extensionsByID[id] + lock.unlock() + return renderExtension + } + + func setRegisteredExtensionOrder(_ orderedIDs: [String]) { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let existingIDs = Set(extensionsByID.keys) + let orderedExistingIDs = orderedIDs.filter(existingIDs.contains) + let remainingIDs = extensionOrder.filter { + existingIDs.contains($0) && !orderedExistingIDs.contains($0) + } + extensionOrder = orderedExistingIDs + remainingIDs + lock.unlock() + } + + @discardableResult + public func register(_ renderExtension: any RenderExtension) -> RenderExtensionRegistrationResult { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + if let pluginID = RenderExtensionPluginRegistry.shared.installedPluginID( + containingExtensionID: renderExtension.id + ) { + return .rejectedPluginOwnership( + extensionID: renderExtension.id, + pluginID: pluginID + ) + } + + return registerExtensionLocked(renderExtension) + } + + func registerPluginOwnedExtension( + _ renderExtension: any RenderExtension + ) -> RenderExtensionRegistrationResult { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + return registerExtensionLocked(renderExtension) + } + + private func registerExtensionLocked( + _ renderExtension: any RenderExtension + ) -> RenderExtensionRegistrationResult { + lock.lock() + let previousExtension = extensionsByID[renderExtension.id] + lock.unlock() + + removeOwnedPipelineArtifacts(ownerID: renderExtension.id) + let report = registerAvailableArtifacts(for: renderExtension) + + guard report.succeeded else { + removeOwnedPipelineArtifacts(ownerID: renderExtension.id) + if report.resourcesCommitted { + RenderResourceRegistry.shared.removeResources(ownerID: renderExtension.id) + } + + if let previousExtension { + let restorationReport = report.resourcesCommitted + ? registerAvailableArtifacts(for: previousExtension) + : registerAvailablePipelineArtifactsReport(for: previousExtension) + if !restorationReport.succeeded { + removeOwnedArtifacts(ownerID: renderExtension.id) + removeRegisteredExtension(id: renderExtension.id) + logRegistrationFailures( + ownerID: previousExtension.id, + conflicts: restorationReport.conflicts, + shaderLibraryErrors: restorationReport.shaderLibraryErrors, + pipelineErrors: restorationReport.pipelineErrors, + validationErrors: restorationReport.resourceValidationErrors + ) + } + } + + lock.lock() + registrationConflictsByExtensionID[renderExtension.id] = report.conflicts + shaderLibraryErrorsByExtensionID[renderExtension.id] = report.shaderLibraryErrors + pipelineErrorsByExtensionID[renderExtension.id] = report.pipelineErrors + resourceValidationErrorsByExtensionID[renderExtension.id] = report.resourceValidationErrors + lock.unlock() + logRegistrationFailures( + ownerID: renderExtension.id, + conflicts: report.conflicts, + shaderLibraryErrors: report.shaderLibraryErrors, + pipelineErrors: report.pipelineErrors, + validationErrors: report.resourceValidationErrors + ) + if !report.shaderLibraryErrors.isEmpty || !report.pipelineErrors.isEmpty { + return .rejectedArtifacts( + conflicts: report.conflicts, + shaderLibraryErrors: report.shaderLibraryErrors, + pipelineErrors: report.pipelineErrors, + resourceValidationErrors: report.resourceValidationErrors + ) + } + if report.resourceValidationErrors.isEmpty { + return .rejected(report.conflicts) + } + return .rejectedResources( + conflicts: report.conflicts, + validationErrors: report.resourceValidationErrors + ) + } + + lock.lock() + if extensionsByID[renderExtension.id] == nil { + extensionOrder.append(renderExtension.id) + } + extensionsByID[renderExtension.id] = renderExtension + registrationConflictsByExtensionID.removeValue(forKey: renderExtension.id) + shaderLibraryErrorsByExtensionID.removeValue(forKey: renderExtension.id) + pipelineErrorsByExtensionID.removeValue(forKey: renderExtension.id) + resourceValidationErrorsByExtensionID.removeValue(forKey: renderExtension.id) + graphValidationErrorsByExtensionID.removeValue(forKey: renderExtension.id) + lock.unlock() + return .registered + } + + public func unregister(id: String) { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + if let pluginID = RenderExtensionPluginRegistry.shared.installedPluginID( + containingExtensionID: id + ) { + RenderExtensionPluginRegistry.shared.uninstall(id: pluginID) + return + } + + unregisterExtensionLocked(id: id) + } + + func unregisterPluginOwnedExtension(id: String) { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + unregisterExtensionLocked(id: id) + } + + private func unregisterExtensionLocked(id: String) { + lock.lock() + extensionsByID.removeValue(forKey: id) + extensionOrder.removeAll { $0 == id } + registrationConflictsByExtensionID.removeValue(forKey: id) + shaderLibraryErrorsByExtensionID.removeValue(forKey: id) + pipelineErrorsByExtensionID.removeValue(forKey: id) + resourceValidationErrorsByExtensionID.removeValue(forKey: id) + graphValidationErrorsByExtensionID.removeValue(forKey: id) + lock.unlock() + + removeOwnedArtifacts(ownerID: id) + } + + public func removeAll() { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + extensionsByID.removeAll() + extensionOrder.removeAll() + registrationConflictsByExtensionID.removeAll() + shaderLibraryErrorsByExtensionID.removeAll() + pipelineErrorsByExtensionID.removeAll() + resourceValidationErrorsByExtensionID.removeAll() + graphValidationErrorsByExtensionID.removeAll() + lock.unlock() + RenderExtensionPluginRegistry.shared.removeAllMetadata() + RenderShaderLibraryManager.shared.removeAll() + PipelineManager.shared.removeAllExtensionPipelines() + RenderResourceRegistry.shared.removeAll() + ComputePipelineManager.shared.removeAll() + RenderExtensionArgumentBufferRegistry.shared.removeAll() + } + + public func registeredIDs() -> [String] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let ids = extensionOrder + lock.unlock() + return ids + } + + public func registrationConflicts(forExtensionID id: String) -> [RenderExtensionArtifactConflict] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let conflicts = registrationConflictsByExtensionID[id] ?? [] + lock.unlock() + return conflicts + } + + public func resourceValidationErrors( + forExtensionID id: String + ) -> [RenderExtensionResourceValidationError] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let errors = resourceValidationErrorsByExtensionID[id] ?? [] + lock.unlock() + return errors + } + + public func shaderLibraryErrors( + forExtensionID id: String + ) -> [RenderShaderLibraryLoadingError] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let errors = shaderLibraryErrorsByExtensionID[id] ?? [] + lock.unlock() + return errors + } + + public func pipelineErrors( + forExtensionID id: String + ) -> [RenderExtensionPipelineError] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let errors = pipelineErrorsByExtensionID[id] ?? [] + lock.unlock() + return errors + } + + public func resourceAllocationErrors( + forExtensionID id: String + ) -> [RenderExtensionResourceAllocationError] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + return RenderResourceRegistry.shared.allocationErrors(ownerID: id) + } + + public func graphValidationErrors(forExtensionID id: String) -> [RenderGraphError] { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let errors = graphValidationErrorsByExtensionID[id] ?? [] + lock.unlock() + return errors + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context: RenderGraphBuildContext + ) { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let orderedExtensions = extensionOrder.compactMap { extensionsByID[$0] } + lock.unlock() + + for renderExtension in orderedExtensions { + lock.lock() + let isStillRegistered = extensionsByID[renderExtension.id] != nil + lock.unlock() + guard isStillRegistered else { continue } + + builder.beginExtensionRegistration(id: renderExtension.id) + renderExtension.buildGraph(&builder, context: context) + let report = builder.endExtensionRegistration() + if !report.succeeded { + removeOwnedArtifacts(ownerID: renderExtension.id) + removeRegisteredExtension( + id: renderExtension.id, + conflicts: report.conflicts, + graphValidationErrors: report.validationErrors + ) + logRegistrationConflicts(report.conflicts) + logGraphValidationErrors( + report.validationErrors, + ownerID: renderExtension.id + ) + let pluginExtensionIDs = RenderExtensionPluginRegistry.shared.invalidateInstalledPlugin( + containingExtensionID: renderExtension.id, + artifactConflicts: report.conflicts, + graphValidationErrors: report.validationErrors + ) + if !pluginExtensionIDs.isEmpty { + builder.removeExtensionContributions( + extensionIDs: Set(pluginExtensionIDs) + ) + for extensionID in pluginExtensionIDs where extensionID != renderExtension.id { + unregister(id: extensionID) + } + } + } + } + } + + /// Removes extensions responsible for whole-graph validation failures. + /// Plugin-owned failures invalidate every extension supplied by that plugin. + @discardableResult + func rejectGraphValidationFailures( + _ report: RenderGraphValidationReport + ) -> Set { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + let errorsByExtensionID = report.errorsByExtensionID() + guard !errorsByExtensionID.isEmpty else { return [] } + + lock.lock() + let orderedFailingIDs = extensionOrder.filter { errorsByExtensionID[$0] != nil } + lock.unlock() + + let pluginIDsByExtensionID = Dictionary(uniqueKeysWithValues: orderedFailingIDs.compactMap { + extensionID in + RenderExtensionPluginRegistry.shared.installedPluginID( + containingExtensionID: extensionID + ).map { (extensionID, $0) } + }) + var removedExtensionIDs: Set = [] + var processedPluginIDs: Set = [] + + for extensionID in orderedFailingIDs { + let errors = errorsByExtensionID[extensionID] ?? [] + if let pluginID = pluginIDsByExtensionID[extensionID] { + guard processedPluginIDs.insert(pluginID).inserted else { continue } + let pluginFailingIDs = orderedFailingIDs.filter { + pluginIDsByExtensionID[$0] == pluginID + } + let pluginErrors = pluginFailingIDs.flatMap { + errorsByExtensionID[$0] ?? [] + } + let pluginExtensionIDs = RenderExtensionPluginRegistry.shared.invalidateInstalledPlugin( + containingExtensionID: extensionID, + graphValidationErrors: pluginErrors + ) + for pluginExtensionID in pluginExtensionIDs { + unregisterExtensionLocked(id: pluginExtensionID) + removedExtensionIDs.insert(pluginExtensionID) + } + for failingID in pluginFailingIDs { + removeRegisteredExtension( + id: failingID, + graphValidationErrors: errorsByExtensionID[failingID] ?? [] + ) + logGraphValidationErrors( + errorsByExtensionID[failingID] ?? [], + ownerID: failingID + ) + } + continue + } else { + unregisterExtensionLocked(id: extensionID) + removeRegisteredExtension( + id: extensionID, + graphValidationErrors: errors + ) + removedExtensionIDs.insert(extensionID) + } + + logGraphValidationErrors(errors, ownerID: extensionID) + } + return removedExtensionIDs + } + + func registerPipelines() { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + + lock.lock() + let orderedExtensions = extensionOrder.compactMap { extensionsByID[$0] } + lock.unlock() + + for renderExtension in orderedExtensions { + lock.lock() + let isStillRegistered = extensionsByID[renderExtension.id] != nil + lock.unlock() + guard isStillRegistered else { continue } + + removeOwnedPipelineArtifacts(ownerID: renderExtension.id) + let report = registerAvailablePipelineArtifacts(for: renderExtension) + if !report.succeeded { + removeOwnedArtifacts(ownerID: renderExtension.id) + removeRegisteredExtension( + id: renderExtension.id, + conflicts: report.conflicts, + shaderLibraryErrors: report.shaderLibraryErrors, + pipelineErrors: report.pipelineErrors + ) + logRegistrationFailures( + ownerID: renderExtension.id, + conflicts: report.conflicts, + shaderLibraryErrors: report.shaderLibraryErrors, + pipelineErrors: report.pipelineErrors, + validationErrors: [] + ) + let pluginExtensionIDs = RenderExtensionPluginRegistry.shared.invalidateInstalledPlugin( + containingExtensionID: renderExtension.id, + artifactConflicts: report.conflicts, + shaderLibraryErrors: report.shaderLibraryErrors, + pipelineErrors: report.pipelineErrors + ) + for extensionID in pluginExtensionIDs where extensionID != renderExtension.id { + unregister(id: extensionID) + } + } + } + } + + private func registerAvailableArtifacts( + for renderExtension: any RenderExtension + ) -> RenderExtensionArtifactRegistrationReport { + let pipelineReport = registerAvailablePipelineArtifacts(for: renderExtension) + let resourceReport = RenderResourceRegistry.shared.registerResources( + ownerID: renderExtension.id, + commitIfValid: pipelineReport.succeeded + ) { registry in + renderExtension.registerResources(registry) + } + return RenderExtensionArtifactRegistrationReport( + conflicts: pipelineReport.conflicts + resourceReport.conflicts, + shaderLibraryErrors: pipelineReport.shaderLibraryErrors, + pipelineErrors: pipelineReport.pipelineErrors, + resourceValidationErrors: resourceReport.validationErrors, + resourcesCommitted: resourceReport.committed + ) + } + + private func registerAvailablePipelineArtifactsReport( + for renderExtension: any RenderExtension + ) -> RenderExtensionArtifactRegistrationReport { + let pipelineReport = registerAvailablePipelineArtifacts(for: renderExtension) + return RenderExtensionArtifactRegistrationReport( + conflicts: pipelineReport.conflicts, + shaderLibraryErrors: pipelineReport.shaderLibraryErrors, + pipelineErrors: pipelineReport.pipelineErrors, + resourceValidationErrors: [], + resourcesCommitted: false + ) + } + + private func registerAvailablePipelineArtifacts( + for renderExtension: any RenderExtension + ) -> RenderExtensionPipelineArtifactRegistrationReport { + var conflicts = RenderExtensionArgumentBufferRegistry.shared.registerArgumentBuffers(ownerID: renderExtension.id) { registry in + renderExtension.registerArgumentBuffers(registry) + } + var shaderLibraryErrors: [RenderShaderLibraryLoadingError] = [] + var pipelineErrors: [RenderExtensionPipelineError] = [] + + if renderInfo.device != nil { + let shaderReport = RenderShaderLibraryManager.shared.registerLibraries(ownerID: renderExtension.id) { registry in + renderExtension.registerShaderLibraries(registry) + } + conflicts += shaderReport.conflicts + shaderLibraryErrors = shaderReport.loadingErrors + } + + guard shaderLibraryErrors.isEmpty else { + return RenderExtensionPipelineArtifactRegistrationReport( + conflicts: conflicts, + shaderLibraryErrors: shaderLibraryErrors, + pipelineErrors: pipelineErrors + ) + } + + if renderInfo.device != nil, renderInfo.library != nil { + let renderPipelineReport = PipelineManager.shared.registerPipelines(ownerID: renderExtension.id) { registry in + renderExtension.registerPipelines(registry) + } + conflicts += renderPipelineReport.conflicts + pipelineErrors += renderPipelineReport.errors + let computePipelineReport = ComputePipelineManager.shared.registerPipelines(ownerID: renderExtension.id) { registry in + renderExtension.registerComputePipelines(registry) + } + conflicts += computePipelineReport.conflicts + pipelineErrors += computePipelineReport.errors + } + return RenderExtensionPipelineArtifactRegistrationReport( + conflicts: conflicts, + shaderLibraryErrors: shaderLibraryErrors, + pipelineErrors: pipelineErrors + ) + } + + private func removeOwnedArtifacts(ownerID: String) { + removeOwnedPipelineArtifacts(ownerID: ownerID) + RenderResourceRegistry.shared.removeResources(ownerID: ownerID) + } + + private func removeOwnedPipelineArtifacts(ownerID: String) { + RenderExtensionArgumentBufferRegistry.shared.removeArgumentBuffers(ownerID: ownerID) + RenderShaderLibraryManager.shared.removeLibraries(ownerID: ownerID) + PipelineManager.shared.removePipelines(ownerID: ownerID) + ComputePipelineManager.shared.removePipelines(ownerID: ownerID) + } + + private func removeRegisteredExtension( + id: String, + conflicts: [RenderExtensionArtifactConflict]? = nil, + shaderLibraryErrors: [RenderShaderLibraryLoadingError]? = nil, + pipelineErrors: [RenderExtensionPipelineError]? = nil, + graphValidationErrors: [RenderGraphError]? = nil + ) { + lock.lock() + extensionsByID.removeValue(forKey: id) + extensionOrder.removeAll { $0 == id } + if let conflicts { + registrationConflictsByExtensionID[id] = conflicts + shaderLibraryErrorsByExtensionID.removeValue(forKey: id) + pipelineErrorsByExtensionID.removeValue(forKey: id) + resourceValidationErrorsByExtensionID.removeValue(forKey: id) + } + if let shaderLibraryErrors { + shaderLibraryErrorsByExtensionID[id] = shaderLibraryErrors + } + if let pipelineErrors { + pipelineErrorsByExtensionID[id] = pipelineErrors + } + if let graphValidationErrors { + graphValidationErrorsByExtensionID[id] = graphValidationErrors + } + lock.unlock() + } + + private func logRegistrationConflicts(_ conflicts: [RenderExtensionArtifactConflict]) { + for conflict in conflicts { + Logger.logWarning(message: "[RenderExtension] \(conflict.description)") + } + } + + private func logGraphValidationErrors(_ errors: [RenderGraphError], ownerID: String) { + for error in errors { + Logger.logWarning( + message: "[RenderExtension] Extension '\(ownerID)' has an invalid graph contribution: \(error.description)" + ) + } + } + + private func logRegistrationFailures( + ownerID: String, + conflicts: [RenderExtensionArtifactConflict], + shaderLibraryErrors: [RenderShaderLibraryLoadingError], + pipelineErrors: [RenderExtensionPipelineError], + validationErrors: [RenderExtensionResourceValidationError] + ) { + logRegistrationConflicts(conflicts) + for error in shaderLibraryErrors { + Logger.logWarning(message: "[RenderExtension] Extension '\(ownerID)' cannot load shaders: \(error.description)") + } + for error in pipelineErrors { + Logger.logWarning(message: "[RenderExtension] Extension '\(ownerID)' cannot create pipelines: \(error.description)") + } + for error in validationErrors { + Logger.logWarning(message: "[RenderExtension] Extension '\(ownerID)' cannot register resources: \(error.description)") + } + } +} + +private struct RenderExtensionArtifactRegistrationReport { + let conflicts: [RenderExtensionArtifactConflict] + let shaderLibraryErrors: [RenderShaderLibraryLoadingError] + let pipelineErrors: [RenderExtensionPipelineError] + let resourceValidationErrors: [RenderExtensionResourceValidationError] + let resourcesCommitted: Bool + + var succeeded: Bool { + conflicts.isEmpty && shaderLibraryErrors.isEmpty && pipelineErrors.isEmpty + && resourceValidationErrors.isEmpty + } +} + +private struct RenderExtensionPipelineArtifactRegistrationReport { + let conflicts: [RenderExtensionArtifactConflict] + let shaderLibraryErrors: [RenderShaderLibraryLoadingError] + let pipelineErrors: [RenderExtensionPipelineError] + + var succeeded: Bool { + conflicts.isEmpty && shaderLibraryErrors.isEmpty && pipelineErrors.isEmpty + } +} diff --git a/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift b/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift new file mode 100644 index 00000000..e3c6f0a8 --- /dev/null +++ b/Sources/UntoldEngine/Renderer/SampleRenderExtension.swift @@ -0,0 +1,84 @@ +// +// SampleRenderExtension.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd + +/// Minimal opt-in render extension used as a reference implementation. +/// +/// The extension allocates a viewport-sized scratch texture and clears it from +/// a staged graph pass. It intentionally does not write to the scene output. +public final class SampleRenderExtension: RenderExtension, @unchecked Sendable { + public let id: String + public let passID: String + public let scratchTextureID: String + public let stage: RenderStage + public let clearColor: SIMD4 + + public init( + id: String = "sample.renderExtension", + passID: String = "sample.renderExtension.clearScratch", + scratchTextureID: String = "sample.renderExtension.scratch", + stage: RenderStage = .beforeOutput, + clearColor: SIMD4 = SIMD4(0.0, 0.0, 0.0, 0.0) + ) { + self.id = id + self.passID = passID + self.scratchTextureID = scratchTextureID + self.stage = stage + self.clearColor = clearColor + } + + public func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: scratchTextureID, + label: "Sample Render Extension Scratch", + size: .viewportScale(1.0), + pixelFormat: .rgba16Float, + usage: [.renderTarget, .shaderRead] + ) + ) + } + + public func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: passID, + stage: stage, + resources: [ + .texture(RenderTextureResourceID(scratchTextureID), access: .renderTarget), + ] + ) { [scratchTextureID, clearColor] context in + guard let scratchTexture = context.resources.texture(scratchTextureID) else { + return + } + + let descriptor = MTLRenderPassDescriptor() + descriptor.colorAttachments[0].texture = scratchTexture + descriptor.colorAttachments[0].loadAction = .clear + descriptor.colorAttachments[0].storeAction = .store + descriptor.colorAttachments[0].clearColor = MTLClearColorMake( + clearColor.x, + clearColor.y, + clearColor.z, + clearColor.w + ) + + guard let encoder = context.commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { + return + } + encoder.label = "Sample Render Extension Scratch Clear" + encoder.endEncoding() + } + } +} diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 708b15f9..7d030a28 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -147,6 +147,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { initBufferResources() PipelineManager.shared.initRenderPipelines(configuration.initRenderPipelineBlocks) + RenderExtensionRegistry.shared.registerPipelines() initSizeableResources() // TODO: Find a better name function @@ -192,6 +193,7 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { let previousOpaqueSampleCount = renderInfo.opaqueSampleCount initTextureResources() + RenderResourceRegistry.shared.recreateResources() initRenderPassDescriptors() if previousOpaqueSampleCount != renderInfo.opaqueSampleCount { updateOpaquePipelinesForSampleCount() diff --git a/Sources/UntoldEngine/Systems/GraphBuilder.swift b/Sources/UntoldEngine/Systems/GraphBuilder.swift index 4fe454e7..9ba57806 100644 --- a/Sources/UntoldEngine/Systems/GraphBuilder.swift +++ b/Sources/UntoldEngine/Systems/GraphBuilder.swift @@ -12,39 +12,1130 @@ import Foundation import Metal -enum GraphError: Error { +public enum RenderGraphError: Error, Equatable, CustomStringConvertible, Sendable { case cycleDetected(String) + case duplicatePassID(String) + case missingDependency(passID: String, dependencyID: String) + case missingResource(passID: String, kind: RenderExtensionArtifactKind, resourceID: String) + case inaccessibleResource( + passID: String, + kind: RenderExtensionArtifactKind, + resourceID: String, + requestedOwnerID: String, + existingOwnerID: String? + ) + case incompatibleResourceUsage( + passID: String, + kind: RenderExtensionArtifactKind, + resourceID: String, + access: RenderGraphResourceAccess + ) + case readBeforeWrite( + passID: String, + kind: RenderExtensionArtifactKind, + resourceID: String + ) + case unorderedResourceWrites( + kind: RenderExtensionArtifactKind, + resourceID: String, + firstPassID: String, + secondPassID: String + ) + case unresolvedStages([RenderStage]) + + public var description: String { + switch self { + case let .cycleDetected(message): + return message + case let .duplicatePassID(passID): + return "Duplicate render pass id: \(passID)" + case let .missingDependency(passID, dependencyID): + return "Render pass '\(passID)' depends on missing pass '\(dependencyID)'" + case let .missingResource(passID, kind, resourceID): + return "Render pass '\(passID)' references missing \(kind.rawValue) resource '\(resourceID)'" + case let .inaccessibleResource(passID, kind, resourceID, requestedOwnerID, existingOwnerID): + let owner = existingOwnerID ?? "engine or unscoped registration" + return "Render pass '\(passID)' owned by '\(requestedOwnerID)' cannot access \(kind.rawValue) resource '\(resourceID)' owned by \(owner)" + case let .incompatibleResourceUsage(passID, kind, resourceID, access): + return "Render pass '\(passID)' declares unsupported \(access.description) access for \(kind.rawValue) resource '\(resourceID)'" + case let .readBeforeWrite(passID, kind, resourceID): + return "Render pass '\(passID)' reads \(kind.rawValue) resource '\(resourceID)' before any declared writer" + case let .unorderedResourceWrites(kind, resourceID, firstPassID, secondPassID): + return "Render passes '\(firstPassID)' and '\(secondPassID)' write \(kind.rawValue) resource '\(resourceID)' without an ordering dependency" + case let .unresolvedStages(stages): + let names = stages.map(\.rawValue).joined(separator: ", ") + return "Unresolved render extension stage(s): \(names)" + } + } +} + +public struct RenderGraphResourceAccess: OptionSet, Hashable, Sendable, CustomStringConvertible { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let read = RenderGraphResourceAccess(rawValue: 1 << 0) + public static let write = RenderGraphResourceAccess(rawValue: 1 << 1) + public static let renderTarget = RenderGraphResourceAccess(rawValue: 1 << 2) + + public var description: String { + var names: [String] = [] + if contains(.read) { names.append("read") } + if contains(.write) { names.append("write") } + if contains(.renderTarget) { names.append("renderTarget") } + let knownBits = Self.read.rawValue | Self.write.rawValue | Self.renderTarget.rawValue + let unknownBits = rawValue & ~knownBits + if unknownBits != 0 { names.append("unknown(\(unknownBits))") } + return names.isEmpty ? "empty" : names.joined(separator: "+") + } +} + +public enum RenderGraphResourceUsage: Hashable, Sendable { + case texture(RenderTextureResourceID, access: RenderGraphResourceAccess) + case buffer(RenderBufferResourceID, access: RenderGraphResourceAccess) +} + +public enum RenderStage: String, CaseIterable, Sendable { + case afterOpaqueLighting + case beforeTransparency + case afterTransparency + case beforePostProcess + case afterPostProcess + case beforeComposite + case beforeLook + case beforeOutput +} + +public struct RenderGraphBuildContext: Sendable { + public let viewport: SIMD2 + public let immersionStyle: UntoldImmersionMode + public let currentEye: Int + + public init( + viewport: SIMD2, + immersionStyle: UntoldImmersionMode, + currentEye: Int + ) { + self.viewport = viewport + self.immersionStyle = immersionStyle + self.currentEye = currentEye + } +} + +public struct RenderPassContext { + public let commandBuffer: MTLCommandBuffer + public let device: MTLDevice + public let viewport: SIMD2 + public let colorFormat: MTLPixelFormat + public let depthFormat: MTLPixelFormat + public let immersionStyle: UntoldImmersionMode + public let currentEye: Int + public let resources: RenderResourceAccess + public let computePipelines: ComputePipelineAccess + + public init( + commandBuffer: MTLCommandBuffer, + device: MTLDevice, + viewport: SIMD2, + colorFormat: MTLPixelFormat, + depthFormat: MTLPixelFormat, + immersionStyle: UntoldImmersionMode, + currentEye: Int, + resources: RenderResourceAccess = RenderResourceAccess(), + computePipelines: ComputePipelineAccess = ComputePipelineAccess() + ) { + self.commandBuffer = commandBuffer + self.device = device + self.viewport = viewport + self.colorFormat = colorFormat + self.depthFormat = depthFormat + self.immersionStyle = immersionStyle + self.currentEye = currentEye + self.resources = resources + self.computePipelines = computePipelines + } } -public struct RenderPass { - public let id: String +public typealias RenderGraphPassExecution = (RenderPassContext) -> Void + +struct RenderPass { + let id: String var dependencies: [String] + var inferredDependencies: [String] var execute: ((MTLCommandBuffer) -> Void)? + var resourceUsages: [RenderGraphResourceUsage] + let owner: RenderGraphPassOwner + let stage: RenderStage? - public init(id: String, dependencies: [String], execute: ((MTLCommandBuffer) -> Void)?) { + init( + id: String, + dependencies: [String], + inferredDependencies: [String] = [], + execute: ((MTLCommandBuffer) -> Void)?, + resourceUsages: [RenderGraphResourceUsage] = [], + owner: RenderGraphPassOwner = .engine, + stage: RenderStage? = nil + ) { self.id = id self.dependencies = dependencies + self.inferredDependencies = inferredDependencies self.execute = execute + self.resourceUsages = resourceUsages + self.owner = owner + self.stage = stage + } +} + +private struct PendingRenderGraphPass { + let id: String + let dependencies: [String] + let resourceUsages: [RenderGraphResourceUsage] + let execute: RenderGraphPassExecution? + let owner: RenderGraphPassOwner +} + +private struct RenderGraphRegistrationError { + let error: RenderGraphError + let ownerID: String? +} + +struct RenderGraphExtensionRegistrationReport { + let conflicts: [RenderExtensionArtifactConflict] + let validationErrors: [RenderGraphError] + + var succeeded: Bool { + conflicts.isEmpty && validationErrors.isEmpty + } +} + +enum RenderGraphPassOwner: Equatable { + case reservedEngine + case engine + case renderExtension(String) + + var extensionID: String? { + if case let .renderExtension(id) = self { + return id + } + return nil + } +} + +/// Immutable pass metadata and execution captured when a render graph is compiled. +struct CompiledRenderGraphPass { + let id: String + let dependencies: [String] + let inferredDependencies: [String] + let resourceUsages: [RenderGraphResourceUsage] + let owner: RenderGraphPassOwner + let stage: RenderStage? + let execute: ((MTLCommandBuffer) -> Void)? +} + +/// A validated render graph with one deterministic execution order. +struct CompiledRenderGraph { + let passesByID: [String: CompiledRenderGraphPass] + let executionOrder: [String] + let orderedPasses: [CompiledRenderGraphPass] + let resourcePlan: CompiledRenderGraphResourcePlan + let optimizationReport: CompiledRenderGraphOptimizationReport + + init(orderedPasses: [CompiledRenderGraphPass]) { + self.orderedPasses = orderedPasses + passesByID = Dictionary( + uniqueKeysWithValues: orderedPasses.map { ($0.id, $0) } + ) + executionOrder = orderedPasses.map(\.id) + let compiledResourcePlan = compileRenderGraphResourcePlan(orderedPasses) + resourcePlan = compiledResourcePlan + optimizationReport = compileRenderGraphOptimizationReport( + orderedPasses, + resourcePlan: compiledResourcePlan + ) + } +} + +struct RenderGraphValidationDiagnostic: Equatable { + let error: RenderGraphError + let ownerID: String? +} + +struct RenderGraphValidationReport: Equatable { + let diagnostics: [RenderGraphValidationDiagnostic] + + var isValid: Bool { + diagnostics.isEmpty + } + + var errors: [RenderGraphError] { + diagnostics.map(\.error) + } + + func errorsByExtensionID() -> [String: [RenderGraphError]] { + Dictionary(grouping: diagnostics.compactMap { diagnostic in + diagnostic.ownerID.map { ($0, diagnostic.error) } + }, by: { $0.0 }).mapValues { entries in + entries.map(\.1) + } + } +} + +struct RenderGraphCompilationAnalysis { + let compiledGraph: CompiledRenderGraph? + let validationReport: RenderGraphValidationReport + let scheduledGraph: [String: RenderPass] +} + +public struct RenderGraphBuilder { + private var graph: [String: RenderPass] + private var pendingStagePasses: [RenderStage: [PendingRenderGraphPass]] + private var passOwners: [String: RenderGraphPassOwner] + private var registrationErrors: [RenderGraphRegistrationError] + private var currentExtensionID: String? + private var currentExtensionConflicts: [RenderExtensionArtifactConflict] + + init( + graph: [String: RenderPass] = [:], + reservedPassIDs: Set = [] + ) { + self.graph = graph + pendingStagePasses = [:] + passOwners = Dictionary( + uniqueKeysWithValues: graph.map { ($0.key, $0.value.owner) } + ) + for passID in reservedPassIDs where passOwners[passID] == nil { + passOwners[passID] = .reservedEngine + } + registrationErrors = [] + currentExtensionID = nil + currentExtensionConflicts = [] + } + + @discardableResult + mutating func addPass( + id: String, + dependencies: [String], + resourceUsages: [RenderGraphResourceUsage] = [], + execute: ((MTLCommandBuffer) -> Void)?, + inferredDependencies: [String] = [], + owner: RenderGraphPassOwner = .engine, + stage: RenderStage? = nil + ) -> Bool { + if let existingOwner = passOwners[id], existingOwner != .reservedEngine { + Logger.logWarning(message: "[RenderGraph] Duplicate pass id ignored: \(id)") + recordRegistrationError(.duplicatePassID(id)) + return false + } + graph[id] = RenderPass( + id: id, + dependencies: dependencies, + inferredDependencies: inferredDependencies, + execute: execute, + resourceUsages: resourceUsages, + owner: owner, + stage: stage + ) + passOwners[id] = owner + return true + } + + @discardableResult + mutating func addPass(_ pass: RenderPass) -> Bool { + addPass( + id: pass.id, + dependencies: pass.dependencies, + resourceUsages: pass.resourceUsages, + execute: pass.execute, + inferredDependencies: pass.inferredDependencies, + owner: pass.owner, + stage: pass.stage + ) + } + + @discardableResult + public mutating func addPass( + id: String, + stage: RenderStage, + resources: [RenderGraphResourceUsage] = [], + execute: RenderGraphPassExecution? + ) -> Bool { + addPass( + id: id, + stage: stage, + dependencies: [], + resources: resources, + execute: execute + ) + } + + @discardableResult + mutating func addPass( + id: String, + stage: RenderStage, + dependencies: [String], + resources: [RenderGraphResourceUsage] = [], + execute: RenderGraphPassExecution? + ) -> Bool { + let owner = currentExtensionID.map(RenderGraphPassOwner.renderExtension) ?? .engine + guard let existingOwner = passOwners[id] else { + recordResourceUsageErrors( + validateRenderGraphResourceUsages( + resources, + passID: id, + ownerID: owner.extensionID + ), + ownerID: owner.extensionID + ) + pendingStagePasses[stage, default: []].append( + PendingRenderGraphPass( + id: id, + dependencies: dependencies, + resourceUsages: resources, + execute: execute, + owner: owner + ) + ) + passOwners[id] = owner + return true + } + + if let currentExtensionID { + let conflict = RenderExtensionArtifactConflict( + kind: .renderPass, + artifactID: id, + requestedOwnerID: currentExtensionID, + existingOwnerID: existingOwner.extensionID + ) + if !currentExtensionConflicts.contains(conflict) { + currentExtensionConflicts.append(conflict) + } + } else { + recordRegistrationError(.duplicatePassID(id)) + } + Logger.logWarning(message: "[RenderGraph] Duplicate staged pass id ignored: \(id)") + return false + } + + mutating func beginExtensionRegistration(id: String) { + precondition(currentExtensionID == nil, "Render extension pass registrations cannot overlap") + currentExtensionID = id + currentExtensionConflicts = [] + } + + mutating func endExtensionRegistration() -> RenderGraphExtensionRegistrationReport { + guard let extensionID = currentExtensionID else { + return RenderGraphExtensionRegistrationReport(conflicts: [], validationErrors: []) + } + let conflicts = currentExtensionConflicts + let validationErrors = registrationErrors.compactMap { registrationError in + registrationError.ownerID == extensionID ? registrationError.error : nil + } + if !conflicts.isEmpty || !validationErrors.isEmpty { + for stage in RenderStage.allCases { + pendingStagePasses[stage]?.removeAll { pass in + pass.owner == .renderExtension(extensionID) + } + } + passOwners = passOwners.filter { _, owner in + owner != .renderExtension(extensionID) + } + registrationErrors.removeAll { $0.ownerID == extensionID } + } + currentExtensionID = nil + currentExtensionConflicts = [] + return RenderGraphExtensionRegistrationReport( + conflicts: conflicts, + validationErrors: validationErrors + ) + } + + mutating func removeExtensionContributions(extensionIDs: Set) { + guard !extensionIDs.isEmpty else { return } + for stage in RenderStage.allCases { + pendingStagePasses[stage]?.removeAll { pass in + guard let ownerID = pass.owner.extensionID else { return false } + return extensionIDs.contains(ownerID) + } + } + let removedPassIDs: [String] = passOwners.compactMap { entry in + guard let ownerID = entry.value.extensionID, + extensionIDs.contains(ownerID) + else { + return nil + } + return entry.key + } + for passID in removedPassIDs { + passOwners.removeValue(forKey: passID) + graph.removeValue(forKey: passID) + } + registrationErrors.removeAll { error in + error.ownerID.map(extensionIDs.contains) == true + } + } + + @discardableResult + mutating func resolveStage(_ stage: RenderStage, after anchorPassID: String?) -> String? { + guard let passes = pendingStagePasses.removeValue(forKey: stage), !passes.isEmpty else { + return anchorPassID + } + + var tail = anchorPassID + for pass in passes { + var dependencies = pass.dependencies + if let tail, !dependencies.contains(tail) { + dependencies.append(tail) + } + + let execution = pass.execute.map { execute in + let resources = RenderResourceAccess(resourceUsages: pass.resourceUsages) + return { commandBuffer in + execute(makeRenderPassContext(commandBuffer: commandBuffer, resources: resources)) + } + } + + graph[pass.id] = RenderPass( + id: pass.id, + dependencies: dependencies, + execute: execution, + resourceUsages: pass.resourceUsages, + owner: pass.owner, + stage: stage + ) + tail = pass.id + } + + return tail + } + + func build() throws -> [String: RenderPass] { + try buildWithCompilation().graph + } + + func buildWithCompilation() throws -> ( + graph: [String: RenderPass], + compiledGraph: CompiledRenderGraph + ) { + let analysis = try analyzeForCompilation() + guard let compiledGraph = analysis.compiledGraph else { + throw analysis.validationReport.errors[0] + } + return (analysis.scheduledGraph, compiledGraph) + } + + func compile() throws -> CompiledRenderGraph { + let analysis = try analyzeForCompilation() + guard let compiledGraph = analysis.compiledGraph else { + throw analysis.validationReport.errors[0] + } + return compiledGraph + } + + func analyzeForCompilation() throws -> RenderGraphCompilationAnalysis { + if let registrationError = registrationErrors.first { + throw registrationError.error + } + + let unresolvedStages = RenderStage.allCases.filter { + pendingStagePasses[$0]?.isEmpty == false + } + guard unresolvedStages.isEmpty else { + throw RenderGraphError.unresolvedStages(unresolvedStages) + } + + return analyzeRenderGraph(graph) + } + + private mutating func recordRegistrationError( + _ error: RenderGraphError, + ownerID: String? = nil + ) { + if !registrationErrors.contains(where: { $0.error == error && $0.ownerID == ownerID }) { + registrationErrors.append(RenderGraphRegistrationError(error: error, ownerID: ownerID)) + } + } + + private mutating func recordResourceUsageErrors( + _ errors: [RenderGraphError], + ownerID: String? + ) { + for error in errors { + recordRegistrationError(error, ownerID: ownerID) + } + } +} + +func validateRenderGraphResourceUsages( + _ usages: [RenderGraphResourceUsage], + passID: String, + ownerID: String? +) -> [RenderGraphError] { + var errors: [RenderGraphError] = [] + let registry = RenderResourceRegistry.shared + let knownAccess: RenderGraphResourceAccess = [.read, .write, .renderTarget] + + for usage in usages { + switch usage { + case let .texture(id, access): + guard let declaration = registry.textureDeclaration(id) else { + errors.append(.missingResource(passID: passID, kind: .texture, resourceID: id.rawValue)) + continue + } + if let ownerID, declaration.ownerID != ownerID { + errors.append( + .inaccessibleResource( + passID: passID, + kind: .texture, + resourceID: id.rawValue, + requestedOwnerID: ownerID, + existingOwnerID: declaration.ownerID + ) + ) + continue + } + + var unsupported = RenderGraphResourceAccess(rawValue: access.rawValue & ~knownAccess.rawValue) + if access.isEmpty { + unsupported = access + } + if access.contains(.read), !declaration.descriptor.usage.contains(.shaderRead) { + unsupported.insert(.read) + } + if access.contains(.write), !declaration.descriptor.usage.contains(.shaderWrite) { + unsupported.insert(.write) + } + if access.contains(.renderTarget), !declaration.descriptor.usage.contains(.renderTarget) { + unsupported.insert(.renderTarget) + } + if access.isEmpty || !unsupported.isEmpty { + errors.append( + .incompatibleResourceUsage( + passID: passID, + kind: .texture, + resourceID: id.rawValue, + access: access.isEmpty ? access : unsupported + ) + ) + } + + case let .buffer(id, access): + guard let declaration = registry.bufferDeclaration(id) else { + errors.append(.missingResource(passID: passID, kind: .buffer, resourceID: id.rawValue)) + continue + } + if let ownerID, declaration.ownerID != ownerID { + errors.append( + .inaccessibleResource( + passID: passID, + kind: .buffer, + resourceID: id.rawValue, + requestedOwnerID: ownerID, + existingOwnerID: declaration.ownerID + ) + ) + continue + } + + let supportedAccess: RenderGraphResourceAccess = [.read, .write] + let unsupported = RenderGraphResourceAccess(rawValue: access.rawValue & ~supportedAccess.rawValue) + if access.isEmpty || !unsupported.isEmpty { + errors.append( + .incompatibleResourceUsage( + passID: passID, + kind: .buffer, + resourceID: id.rawValue, + access: access.isEmpty ? access : unsupported + ) + ) + } + } } + return errors } -public func executeGraph( - _: [String: RenderPass], _ sortedPasses: [RenderPass], _ commandBuffer: MTLCommandBuffer +func makeRenderGraphBuildContext() -> RenderGraphBuildContext { + RenderGraphBuildContext( + viewport: SIMD2( + Int(renderInfo.viewPort?.x ?? 0), + Int(renderInfo.viewPort?.y ?? 0) + ), + immersionStyle: renderInfo.immersionStyle, + currentEye: renderInfo.currentEye + ) +} + +func makeRenderPassContext( + commandBuffer: MTLCommandBuffer, + resources: RenderResourceAccess = RenderResourceAccess() +) -> RenderPassContext { + RenderPassContext( + commandBuffer: commandBuffer, + device: renderInfo.device, + viewport: SIMD2( + Int(renderInfo.viewPort?.x ?? 0), + Int(renderInfo.viewPort?.y ?? 0) + ), + colorFormat: renderInfo.colorPixelFormat, + depthFormat: renderInfo.depthPixelFormat, + immersionStyle: renderInfo.immersionStyle, + currentEye: renderInfo.currentEye, + resources: resources, + computePipelines: ComputePipelineAccess() + ) +} + +func validateGraph(_ graph: [String: RenderPass]) throws { + let report = analyzeRenderGraph(graph).validationReport + if let error = report.errors.first { + throw error + } +} + +func executeGraph(_ graph: CompiledRenderGraph, _ commandBuffer: MTLCommandBuffer) { + for pass in graph.orderedPasses { + pass.execute?(commandBuffer) + } +} + +func compileRenderGraph(_ graph: [String: RenderPass]) throws -> CompiledRenderGraph { + let analysis = analyzeRenderGraph(graph) + guard let compiledGraph = analysis.compiledGraph else { + throw analysis.validationReport.errors[0] + } + return compiledGraph +} + +func analyzeRenderGraph(_ graph: [String: RenderPass]) -> RenderGraphCompilationAnalysis { + var diagnostics = validateMissingRenderGraphDependencies(graph) + diagnostics.append(contentsOf: validateRenderGraphDeclarations(graph)) + var scheduledGraph = graph + let explicitlySortedPasses: [RenderPass]? + if diagnostics.contains(where: { + if case .missingDependency = $0.error { return true } + return false + }) { + explicitlySortedPasses = nil + } else { + do { + explicitlySortedPasses = try topologicalSortGraph(graph: graph) + } catch let error as RenderGraphError { + let passID = renderGraphPassID(for: error) + diagnostics.insert( + RenderGraphValidationDiagnostic( + error: error, + ownerID: passID.flatMap { graph[$0]?.owner.extensionID } + ), + at: 0 + ) + explicitlySortedPasses = nil + } catch { + diagnostics.insert( + RenderGraphValidationDiagnostic( + error: .cycleDetected(error.localizedDescription), + ownerID: nil + ), + at: 0 + ) + explicitlySortedPasses = nil + } + } + + let sortedPasses: [RenderPass]? + if let explicitlySortedPasses { + scheduledGraph = scheduleRenderGraphResourceHazards( + graph, + explicitlySortedPasses: explicitlySortedPasses + ) + do { + sortedPasses = try topologicalSortGraph(graph: scheduledGraph) + } catch let error as RenderGraphError { + let passID = renderGraphPassID(for: error) + diagnostics.append( + RenderGraphValidationDiagnostic( + error: error, + ownerID: passID.flatMap { scheduledGraph[$0]?.owner.extensionID } + ) + ) + sortedPasses = nil + } catch { + diagnostics.append( + RenderGraphValidationDiagnostic( + error: .cycleDetected(error.localizedDescription), + ownerID: nil + ) + ) + sortedPasses = nil + } + } else { + sortedPasses = nil + } + + if let sortedPasses { + diagnostics.append(contentsOf: validateRenderGraphResourceHazards(sortedPasses)) + } + + let report = RenderGraphValidationReport(diagnostics: uniqueDiagnostics(diagnostics)) + guard report.isValid, let sortedPasses else { + return RenderGraphCompilationAnalysis( + compiledGraph: nil, + validationReport: report, + scheduledGraph: scheduledGraph + ) + } + + let passes = sortedPasses.map { pass in + CompiledRenderGraphPass( + id: pass.id, + dependencies: pass.dependencies, + inferredDependencies: pass.inferredDependencies, + resourceUsages: pass.resourceUsages, + owner: pass.owner, + stage: pass.stage, + execute: pass.execute + ) + } + return RenderGraphCompilationAnalysis( + compiledGraph: CompiledRenderGraph(orderedPasses: passes), + validationReport: report, + scheduledGraph: scheduledGraph + ) +} + +private func validateMissingRenderGraphDependencies( + _ graph: [String: RenderPass] +) -> [RenderGraphValidationDiagnostic] { + var diagnostics: [RenderGraphValidationDiagnostic] = [] + for passID in graph.keys.sorted() { + guard let pass = graph[passID] else { continue } + for dependencyID in pass.dependencies where graph[dependencyID] == nil { + diagnostics.append( + RenderGraphValidationDiagnostic( + error: .missingDependency( + passID: pass.id, + dependencyID: dependencyID + ), + ownerID: pass.owner.extensionID + ) + ) + } + } + return diagnostics +} + +private struct RenderGraphResourceKey: Hashable, Comparable { + let kind: RenderExtensionArtifactKind + let id: String + + static func < (lhs: RenderGraphResourceKey, rhs: RenderGraphResourceKey) -> Bool { + if lhs.kind.rawValue != rhs.kind.rawValue { + return lhs.kind.rawValue < rhs.kind.rawValue + } + return lhs.id < rhs.id + } +} + +private func validateRenderGraphDeclarations( + _ graph: [String: RenderPass] +) -> [RenderGraphValidationDiagnostic] { + var diagnostics: [RenderGraphValidationDiagnostic] = [] + for passID in graph.keys.sorted() { + guard let pass = graph[passID] else { continue } + diagnostics += validateRenderGraphResourceUsages( + pass.resourceUsages, + passID: pass.id, + ownerID: pass.owner.extensionID + ).map { error in + RenderGraphValidationDiagnostic( + error: error, + ownerID: pass.owner.extensionID + ) + } + } + return diagnostics +} + +private struct RenderGraphResourceAccessRecord { + let passID: String + let access: RenderGraphResourceAccess +} + +func scheduleRenderGraphResourceHazards( + _ graph: [String: RenderPass], + explicitlySortedPasses: [RenderPass] +) -> [String: RenderPass] { + var scheduledGraph = graph + let accesses = collectRenderGraphResourceAccesses(explicitlySortedPasses) + + for key in accesses.keys.sorted() { + guard var sequence = accesses[key], + let firstWriterIndex = sequence.firstIndex(where: { isWriteAccess($0.access) }) + else { + continue + } + + if firstWriterIndex > 0 { + let firstWriter = sequence[firstWriterIndex] + let leadingReaders = Array(sequence[.. [RenderGraphResourceKey: [RenderGraphResourceAccessRecord]] { + var accesses: [RenderGraphResourceKey: [RenderGraphResourceAccessRecord]] = [:] + for pass in sortedPasses { + var mergedAccess: [RenderGraphResourceKey: RenderGraphResourceAccess] = [:] + for usage in pass.resourceUsages { + let key: RenderGraphResourceKey + let access: RenderGraphResourceAccess + switch usage { + case let .texture(id, declaredAccess): + key = RenderGraphResourceKey(kind: .texture, id: id.rawValue) + access = declaredAccess + case let .buffer(id, declaredAccess): + key = RenderGraphResourceKey(kind: .buffer, id: id.rawValue) + access = declaredAccess + } + mergedAccess[key, default: []].formUnion(access) + } + for key in mergedAccess.keys.sorted() { + accesses[key, default: []].append( + RenderGraphResourceAccessRecord( + passID: pass.id, + access: mergedAccess[key] ?? [] + ) + ) + } + } + return accesses +} + +private func addInferredRenderGraphDependency( + from prerequisiteID: String, + to dependentID: String, + graph: inout [String: RenderPass] ) { + guard prerequisiteID != dependentID, + graph[prerequisiteID] != nil, + var dependent = graph[dependentID], + !dependent.dependencies.contains(prerequisiteID), + !renderPass( + prerequisiteID, + dependsOn: dependentID, + passesByID: graph + ) + else { + return + } + + dependent.dependencies.append(prerequisiteID) + dependent.inferredDependencies.append(prerequisiteID) + graph[dependentID] = dependent +} + +func validateRenderGraphResourceHazards( + _ sortedPasses: [RenderPass] +) -> [RenderGraphValidationDiagnostic] { + let passesByID = Dictionary(uniqueKeysWithValues: sortedPasses.map { ($0.id, $0) }) + var accesses: [RenderGraphResourceKey: [(pass: RenderPass, access: RenderGraphResourceAccess)]] = [:] + for pass in sortedPasses { - pass.execute?(commandBuffer) + var mergedAccess: [RenderGraphResourceKey: RenderGraphResourceAccess] = [:] + for usage in pass.resourceUsages { + let key: RenderGraphResourceKey + let access: RenderGraphResourceAccess + switch usage { + case let .texture(id, declaredAccess): + key = RenderGraphResourceKey(kind: .texture, id: id.rawValue) + access = declaredAccess + case let .buffer(id, declaredAccess): + key = RenderGraphResourceKey(kind: .buffer, id: id.rawValue) + access = declaredAccess + } + mergedAccess[key, default: []].formUnion(access) + } + for key in mergedAccess.keys.sorted() { + accesses[key, default: []].append((pass, mergedAccess[key] ?? [])) + } } + + var diagnostics: [RenderGraphValidationDiagnostic] = [] + for key in accesses.keys.sorted() { + guard let resourceAccesses = accesses[key] else { continue } + let writers = resourceAccesses.filter { isWriteAccess($0.access) } + guard !writers.isEmpty else { continue } + + for reader in resourceAccesses where reader.access.contains(.read) { + let hasOrderedWriter = writers.contains { writer in + writer.pass.id == reader.pass.id || renderPass( + reader.pass.id, + dependsOn: writer.pass.id, + passesByID: passesByID + ) + } + if !hasOrderedWriter { + diagnostics.append( + RenderGraphValidationDiagnostic( + error: .readBeforeWrite( + passID: reader.pass.id, + kind: key.kind, + resourceID: key.id + ), + ownerID: reader.pass.owner.extensionID + ) + ) + } + } + + for firstIndex in writers.indices { + for secondIndex in writers.indices where secondIndex > firstIndex { + let first = writers[firstIndex].pass + let second = writers[secondIndex].pass + let isOrdered = renderPass( + first.id, + dependsOn: second.id, + passesByID: passesByID + ) || renderPass( + second.id, + dependsOn: first.id, + passesByID: passesByID + ) + if !isOrdered { + diagnostics.append( + RenderGraphValidationDiagnostic( + error: .unorderedResourceWrites( + kind: key.kind, + resourceID: key.id, + firstPassID: first.id, + secondPassID: second.id + ), + ownerID: second.owner.extensionID ?? first.owner.extensionID + ) + ) + } + } + } + } + return diagnostics +} + +private func isWriteAccess(_ access: RenderGraphResourceAccess) -> Bool { + !access.intersection([.write, .renderTarget]).isEmpty +} + +private func renderPass( + _ passID: String, + dependsOn dependencyID: String, + passesByID: [String: RenderPass] +) -> Bool { + var pending = passesByID[passID]?.dependencies ?? [] + var visited: Set = [] + while let current = pending.popLast() { + if current == dependencyID { return true } + guard visited.insert(current).inserted else { continue } + pending.append(contentsOf: passesByID[current]?.dependencies ?? []) + } + return false +} + +private func renderGraphPassID(for error: RenderGraphError) -> String? { + switch error { + case let .missingDependency(passID, _), + let .missingResource(passID, _, _), + let .inaccessibleResource(passID, _, _, _, _), + let .incompatibleResourceUsage(passID, _, _, _), + let .readBeforeWrite(passID, _, _): + return passID + case let .unorderedResourceWrites(_, _, _, secondPassID): + return secondPassID + case let .cycleDetected(message): + return message.split(separator: " ").last.map(String.init) + case .duplicatePassID, .unresolvedStages: + return nil + } +} + +private func uniqueDiagnostics( + _ diagnostics: [RenderGraphValidationDiagnostic] +) -> [RenderGraphValidationDiagnostic] { + var result: [RenderGraphValidationDiagnostic] = [] + for diagnostic in diagnostics where !result.contains(diagnostic) { + result.append(diagnostic) + } + return result } /// Creates a Directed Acyclic (non-cyclical) Graph -public func topologicalSortGraph(graph: [String: RenderPass]) throws -> [RenderPass] { +func topologicalSortGraph(graph: [String: RenderPass]) throws -> [RenderPass] { + for passID in graph.keys.sorted() { + guard let pass = graph[passID] else { + continue + } + for dependencyID in pass.dependencies where graph[dependencyID] == nil { + throw RenderGraphError.missingDependency( + passID: pass.id, + dependencyID: dependencyID + ) + } + } + var sortedPasses = [RenderPass]() var visited = Set() var visiting = Set() // Tracks nodes in the current recursion stack func visit(_ pass: RenderPass) throws { if visiting.contains(pass.id) { - throw GraphError.cycleDetected("Cycle detected at node \(pass.id)") + throw RenderGraphError.cycleDetected("Cycle detected at node \(pass.id)") } if visited.contains(pass.id) { return @@ -63,8 +1154,10 @@ public func topologicalSortGraph(graph: [String: RenderPass]) throws -> [RenderP sortedPasses.append(pass) } - for (_, pass) in graph { - try visit(pass) + for passID in graph.keys.sorted() { + if let pass = graph[passID] { + try visit(pass) + } } return sortedPasses diff --git a/Sources/UntoldEngine/Systems/InputSystem+PSVR2.swift b/Sources/UntoldEngine/Systems/InputSystem+PSVR2.swift new file mode 100644 index 00000000..78fd2287 --- /dev/null +++ b/Sources/UntoldEngine/Systems/InputSystem+PSVR2.swift @@ -0,0 +1,209 @@ +// +// InputSystem+PSVR2.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import GameController + +/// Finger dead-zone for touchpad touch detection. Positions below this magnitude +/// are treated as center / no-touch to avoid floating-point noise at rest. +private let psvr2TouchpadEpsilon: Float = 0.01 + +// MARK: - PSVR2 Trigger Effect + +/// Adaptive trigger effects for the PSVR2 Sense controller. All position and +/// strength values are normalized to [0, 1]. For `weapon` and `slopeFeedback`, +/// `endPosition` must be greater than `startPosition`. +public enum PSVR2TriggerEffect: Sendable, Equatable { + /// Disables any active adaptive trigger effect. + case off + /// Constant resistive feedback from `startPosition` onwards. + case feedback(startPosition: Float, strength: Float) + /// Resistance builds from `startPosition` to `endPosition`, then releases — + /// emulating the feel of pulling a trigger on a weapon. + case weapon(startPosition: Float, endPosition: Float, strength: Float) + /// Repeating strike vibration from `startPosition` onwards. + case vibration(startPosition: Float, amplitude: Float, frequency: Float) + /// Linearly interpolated feedback between `startStrength` at `startPosition` + /// and `endStrength` at `endPosition`. + case slopeFeedback(startPosition: Float, endPosition: Float, startStrength: Float, endStrength: Float) +} + +// MARK: - PSVR2 Sense Controller State + +public struct PSVR2SenseControllerState { + public var isConnected = false + + // Buttons unique to the PSVR2 Sense (not on a generic GCExtendedGamepad) + public var createButtonPressed = false + public var homeButtonPressed = false + public var touchpadButtonPressed = false + + // Touchpad surface position (−1…1 per axis); touchpadTouched is true while a finger rests on the pad + public var touchpadX: Float = 0 + public var touchpadY: Float = 0 + public var touchpadTouched = false + + // How much resistance the adaptive trigger is currently applying (0…1) + public var leftAdaptiveTriggerValue: Float = 0 + public var rightAdaptiveTriggerValue: Float = 0 + + // Motion data — populated only when InputSystem.psvr2MotionEnabled is true + public var motionGravityX: Float = 0 + public var motionGravityY: Float = 0 + public var motionGravityZ: Float = 0 + public var motionRotationRateX: Float = 0 + public var motionRotationRateY: Float = 0 + public var motionRotationRateZ: Float = 0 + + public init() {} +} + +// MARK: - InputSystem PSVR2 Hooks (called from controllerDidConnect / controllerDidDisconnect) + +extension InputSystem { + func configurePSVR2IfNeeded(_ controller: GCController) { + guard let dualsense = controller.extendedGamepad as? GCDualSenseGamepad, + isPSVR2SenseController(controller) else { return } + psvr2SenseControllerState = PSVR2SenseControllerState() + psvr2SenseControllerState.isConnected = true + configurePSVR2Handlers(dualsense, controller: controller) + Logger.log(message: "PSVR2 Sense controller detected and configured") + } + + func clearPSVR2IfNeeded(_ controller: GCController) { + guard isPSVR2SenseController(controller), psvr2SenseControllerState.isConnected else { return } + if psvr2Motion?.sensorsRequireManualActivation == true { + psvr2Motion?.sensorsActive = false + } + psvr2Motion = nil + psvr2SenseControllerState = PSVR2SenseControllerState() + psvr2LeftTrigger = nil + psvr2RightTrigger = nil + } + + /// Internal so tests can exercise the touchpadTouched inference without hardware. + func applyTouchpadSurface(x: Float, y: Float) { + psvr2SenseControllerState.touchpadX = x + psvr2SenseControllerState.touchpadY = y + psvr2SenseControllerState.touchpadTouched = abs(x) > psvr2TouchpadEpsilon || abs(y) > psvr2TouchpadEpsilon + } + + /// Internal so tests can verify the motion opt-in guard without hardware. + func applyMotionData(gravityX: Float, gravityY: Float, gravityZ: Float, + rotationRateX: Float, rotationRateY: Float, rotationRateZ: Float) + { + guard psvr2MotionEnabled else { return } + psvr2SenseControllerState.motionGravityX = gravityX + psvr2SenseControllerState.motionGravityY = gravityY + psvr2SenseControllerState.motionGravityZ = gravityZ + psvr2SenseControllerState.motionRotationRateX = rotationRateX + psvr2SenseControllerState.motionRotationRateY = rotationRateY + psvr2SenseControllerState.motionRotationRateZ = rotationRateZ + } +} + +// MARK: - Adaptive Trigger Effect API (public) + +public extension InputSystem { + /// Applies an adaptive trigger effect to the left trigger. + /// Has no effect when no PSVR2 Sense controller is connected. + func setLeftTriggerEffect(_ effect: PSVR2TriggerEffect) { + guard let trigger = psvr2LeftTrigger else { return } + applyEffect(effect, to: trigger) + } + + /// Applies an adaptive trigger effect to the right trigger. + /// Has no effect when no PSVR2 Sense controller is connected. + func setRightTriggerEffect(_ effect: PSVR2TriggerEffect) { + guard let trigger = psvr2RightTrigger else { return } + applyEffect(effect, to: trigger) + } +} + +// MARK: - Detection & Configuration (private to this file) + +private extension InputSystem { + /// GCDualSenseGamepad : GCExtendedGamepad, so cast extendedGamepad to detect the profile. + /// The vendor name further narrows it to the PSVR2 Sense vs a standard DualSense + /// ("DualSense Wireless Controller"). + func isPSVR2SenseController(_ controller: GCController) -> Bool { + guard controller.extendedGamepad is GCDualSenseGamepad else { return false } + let vendor = controller.vendorName?.lowercased() ?? "" + return vendor.contains("vr2") || vendor.contains("playstation vr") + } + + func configurePSVR2Handlers(_ gamepad: GCDualSenseGamepad, controller: GCController) { + // buttonOptions = DualSense "Create" button; buttonHome = PS button. + // Both are nullable on GCExtendedGamepad — not all controllers expose them. + gamepad.buttonOptions?.pressedChangedHandler = { [weak self] _, _, pressed in + self?.psvr2SenseControllerState.createButtonPressed = pressed + } + gamepad.buttonHome?.pressedChangedHandler = { [weak self] _, _, pressed in + self?.psvr2SenseControllerState.homeButtonPressed = pressed + } + + // Touchpad click (non-nullable on GCDualSenseGamepad) + gamepad.touchpadButton.pressedChangedHandler = { [weak self] _, _, pressed in + self?.psvr2SenseControllerState.touchpadButtonPressed = pressed + } + + // touchpadPrimary tracks the first finger's position on the pad surface. + gamepad.touchpadPrimary.valueChangedHandler = { [weak self] _, xValue, yValue in + self?.applyTouchpadSurface(x: xValue, y: yValue) + } + + // Adaptive triggers: store references for later effect application, + // then wire value handlers to track current pull depth. + psvr2LeftTrigger = gamepad.leftTrigger + psvr2RightTrigger = gamepad.rightTrigger + + gamepad.leftTrigger.pressedChangedHandler = { [weak self] _, value, _ in + self?.psvr2SenseControllerState.leftAdaptiveTriggerValue = value + } + gamepad.rightTrigger.pressedChangedHandler = { [weak self] _, value, _ in + self?.psvr2SenseControllerState.rightAdaptiveTriggerValue = value + } + + // Motion — opt-in via psvr2MotionEnabled. The handler calls applyMotionData + // which returns early when the flag is false, so toggling at runtime takes + // effect immediately without re-connecting the controller. + if let motion = controller.motion { + psvr2Motion = motion + if motion.sensorsRequireManualActivation { + motion.sensorsActive = true + } + motion.valueChangedHandler = { [weak self] m in + self?.applyMotionData( + gravityX: Float(m.gravity.x), + gravityY: Float(m.gravity.y), + gravityZ: Float(m.gravity.z), + rotationRateX: Float(m.rotationRate.x), + rotationRateY: Float(m.rotationRate.y), + rotationRateZ: Float(m.rotationRate.z) + ) + } + } + } + + func applyEffect(_ effect: PSVR2TriggerEffect, to trigger: GCDualSenseAdaptiveTrigger) { + switch effect { + case .off: + trigger.setModeOff() + case let .feedback(startPosition, strength): + trigger.setModeFeedbackWithStartPosition(startPosition, resistiveStrength: strength) + case let .weapon(startPosition, endPosition, strength): + trigger.setModeWeaponWithStartPosition(startPosition, endPosition: endPosition, resistiveStrength: strength) + case let .vibration(startPosition, amplitude, frequency): + trigger.setModeVibrationWithStartPosition(startPosition, amplitude: amplitude, frequency: frequency) + case let .slopeFeedback(startPosition, endPosition, startStrength, endStrength): + trigger.setModeSlopeFeedback(startPosition: startPosition, endPosition: endPosition, startStrength: startStrength, endStrength: endStrength) + } + } +} diff --git a/Sources/UntoldEngine/Systems/InputSystem+iOS.swift b/Sources/UntoldEngine/Systems/InputSystem+iOS.swift index 47ec3dd5..4c945cc3 100644 --- a/Sources/UntoldEngine/Systems/InputSystem+iOS.swift +++ b/Sources/UntoldEngine/Systems/InputSystem+iOS.swift @@ -12,138 +12,168 @@ import UIKit #endif +// MARK: - iOS Touch State + +/// Dedicated state for iOS touch gestures. Populated only when running on iOS; +/// all fields stay at their default values on other platforms. +public struct IOSTouchState { + // Single-finger drag + public var isDragging = false + public var dragX: Float = 0 + public var dragY: Float = 0 + public var dragDeltaX: Float = 0 + public var dragDeltaY: Float = 0 + public var dragGestureState: PanGestureState? + + // Tap — brief pulse; auto-clears after ~0.1 s + public var tapped = false + public var tapX: Float = 0 + public var tapY: Float = 0 + + // Double tap — brief pulse; auto-clears after ~0.1 s + public var doubleTapped = false + public var doubleTapX: Float = 0 + public var doubleTapY: Float = 0 + + // Two-finger pan + public var twoFingerPanning = false + public var twoFingerDeltaX: Float = 0 + public var twoFingerDeltaY: Float = 0 + + // Pinch — pinchScaleDelta is the change in scale per frame (positive = spreading) + public var isPinching = false + public var pinchScaleDelta: Float = 0 + + public init() {} +} + +// MARK: - InputSystem iOS Extension + public extension InputSystem { #if !os(iOS) func registerTouchEvents(view _: Any) {} func unregisterTouchEvents() {} #else func registerTouchEvents(view: UIView) { - // Pan gesture (single finger drag - equivalent to mouse drag) - let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) - panGesture.minimumNumberOfTouches = 1 - panGesture.maximumNumberOfTouches = 1 - view.addGestureRecognizer(panGesture) + iosTouchView = view + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + pan.minimumNumberOfTouches = 1 + pan.maximumNumberOfTouches = 1 + view.addGestureRecognizer(pan) + iosTouchGestureRecognizers.append(pan) - // Two-finger pan (for camera movement/orbiting) let twoFingerPan = UIPanGestureRecognizer(target: self, action: #selector(handleTwoFingerPan(_:))) twoFingerPan.minimumNumberOfTouches = 2 twoFingerPan.maximumNumberOfTouches = 2 view.addGestureRecognizer(twoFingerPan) + iosTouchGestureRecognizers.append(twoFingerPan) - // Pinch gesture (for zooming) - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) - view.addGestureRecognizer(pinchGesture) + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) + view.addGestureRecognizer(pinch) + iosTouchGestureRecognizers.append(pinch) - // Tap gesture (for selection/interaction) - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) - view.addGestureRecognizer(tapGesture) + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + view.addGestureRecognizer(tap) + iosTouchGestureRecognizers.append(tap) - // Double tap gesture - let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTapGesture(_:))) - doubleTapGesture.numberOfTapsRequired = 2 - view.addGestureRecognizer(doubleTapGesture) + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTapGesture(_:))) + doubleTap.numberOfTapsRequired = 2 + view.addGestureRecognizer(doubleTap) + iosTouchGestureRecognizers.append(doubleTap) - // Make sure single tap doesn't conflict with double tap - tapGesture.require(toFail: doubleTapGesture) + // Single tap must wait for the double-tap recognizer to fail first + tap.require(toFail: doubleTap) } - // MARK: - Pan Gesture (Single Finger) + func unregisterTouchEvents() { + for recognizer in iosTouchGestureRecognizers { + iosTouchView?.removeGestureRecognizer(recognizer) + } + iosTouchGestureRecognizers.removeAll() + iosTouchView = nil + iosTouchState = IOSTouchState() + } + + // MARK: - Single-finger drag @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { guard let view = gesture.view else { return } - let location = gesture.location(in: view) - let translation = gesture.translation(in: view) switch gesture.state { case .began: - currentPanGestureState = .began - initialPanLocation = location - lastMouseX = Float(location.x) - lastMouseY = Float(location.y) - mouseX = lastMouseX - mouseY = lastMouseY - mouseActive = true + iosTouchState.isDragging = true + iosTouchState.dragGestureState = .began + iosTouchState.dragX = Float(location.x) + iosTouchState.dragY = Float(location.y) + iosTouchState.dragDeltaX = 0 + iosTouchState.dragDeltaY = 0 case .changed: - currentPanGestureState = .changed - lastMouseX = mouseX - lastMouseY = mouseY - mouseX = Float(location.x) - mouseY = Float(location.y) - - mouseDeltaX = Float(translation.x) - mouseDeltaY = Float(translation.y) - - panDelta.x = Float(translation.x) - panDelta.y = Float(translation.y) - - // Reset translation to get delta for next frame + let translation = gesture.translation(in: view) + iosTouchState.dragGestureState = .changed + iosTouchState.dragX = Float(location.x) + iosTouchState.dragY = Float(location.y) + iosTouchState.dragDeltaX = Float(translation.x) + iosTouchState.dragDeltaY = Float(translation.y) gesture.setTranslation(.zero, in: view) case .ended, .cancelled: - currentPanGestureState = .ended - mouseActive = false - mouseDeltaX = 0 - mouseDeltaY = 0 - panDelta = .init(0, 0) + iosTouchState.isDragging = false + iosTouchState.dragGestureState = .ended + iosTouchState.dragDeltaX = 0 + iosTouchState.dragDeltaY = 0 default: break } } - // MARK: - Two-Finger Pan (Camera Orbit) + // MARK: - Two-finger pan @objc private func handleTwoFingerPan(_ gesture: UIPanGestureRecognizer) { guard let view = gesture.view else { return } - let translation = gesture.translation(in: view) - switch gesture.state { case .began: - cameraControlMode = .orbiting - currentPanGestureState = .began + iosTouchState.twoFingerPanning = true + iosTouchState.twoFingerDeltaX = 0 + iosTouchState.twoFingerDeltaY = 0 case .changed: - currentPanGestureState = .changed - panDelta.x = Float(translation.x) - panDelta.y = Float(translation.y) - - // Reset translation for next frame + let translation = gesture.translation(in: view) + iosTouchState.twoFingerDeltaX = Float(translation.x) + iosTouchState.twoFingerDeltaY = Float(translation.y) gesture.setTranslation(.zero, in: view) case .ended, .cancelled: - currentPanGestureState = .ended - cameraControlMode = .idle - panDelta = .init(0, 0) + iosTouchState.twoFingerPanning = false + iosTouchState.twoFingerDeltaX = 0 + iosTouchState.twoFingerDeltaY = 0 default: break } } - // MARK: - Pinch Gesture (Zoom) + // MARK: - Pinch @objc private func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) { switch gesture.state { case .began: - currentPinchGestureState = .began + iosTouchState.isPinching = true + iosTouchState.pinchScaleDelta = 0 previousScale = gesture.scale case .changed: - currentPinchGestureState = .changed - let currentScale = gesture.scale - let scaleDelta = currentScale - previousScale - - // Update pinch delta for zoom - pinchDelta.z = Float(scaleDelta) - - previousScale = currentScale + let current = gesture.scale + iosTouchState.pinchScaleDelta = Float(current - previousScale) + previousScale = current case .ended, .cancelled: - currentPinchGestureState = .ended - pinchDelta = .init(0, 0, 0) + iosTouchState.isPinching = false + iosTouchState.pinchScaleDelta = 0 previousScale = 1.0 default: @@ -151,32 +181,30 @@ public extension InputSystem { } } - // MARK: - Tap Gestures + // MARK: - Tap @objc private func handleTapGesture(_ gesture: UITapGestureRecognizer) { guard let view = gesture.view else { return } let location = gesture.location(in: view) - - mouseX = Float(location.x) - mouseY = Float(location.y) - - // Simulate a quick mouse press/release - keyState.leftMousePressed = true - // You might want to add a delegate method here to notify about tap + iosTouchState.tapX = Float(location.x) + iosTouchState.tapY = Float(location.y) + iosTouchState.tapped = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.keyState.leftMousePressed = false + self?.iosTouchState.tapped = false } } + // MARK: - Double tap + @objc private func handleDoubleTapGesture(_ gesture: UITapGestureRecognizer) { guard let view = gesture.view else { return } let location = gesture.location(in: view) - - mouseX = Float(location.x) - mouseY = Float(location.y) - - // You can add custom double-tap handling here - // For example, reset camera or select entity + iosTouchState.doubleTapX = Float(location.x) + iosTouchState.doubleTapY = Float(location.y) + iosTouchState.doubleTapped = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.iosTouchState.doubleTapped = false + } } #endif } diff --git a/Sources/UntoldEngine/Systems/InputSystem.swift b/Sources/UntoldEngine/Systems/InputSystem.swift index e75af2b6..0a8dc8cc 100644 --- a/Sources/UntoldEngine/Systems/InputSystem.swift +++ b/Sources/UntoldEngine/Systems/InputSystem.swift @@ -13,6 +13,9 @@ import simd #if os(macOS) import AppKit #endif +#if os(iOS) + import UIKit +#endif public struct GameControllerState { public var aPressed = false @@ -64,6 +67,18 @@ public final class InputSystem: @unchecked Sendable { public var keyState = KeyState() public var gameControllerState = GameControllerState() public var currentGameController: GCExtendedGamepad? + public var psvr2SenseControllerState = PSVR2SenseControllerState() + public var psvr2MotionEnabled = false + var psvr2LeftTrigger: GCDualSenseAdaptiveTrigger? + var psvr2RightTrigger: GCDualSenseAdaptiveTrigger? + var psvr2Motion: GCMotion? + + public var iosTouchState = IOSTouchState() + + #if os(iOS) + var iosTouchGestureRecognizers: [UIGestureRecognizer] = [] + weak var iosTouchView: UIView? + #endif // Shared state public var currentPanGestureState: PanGestureState? @@ -106,12 +121,14 @@ public final class InputSystem: @unchecked Sendable { guard let controller = note.object as? GCController, let gameController = controller.extendedGamepad else { return } currentGameController = gameController configureGameControllerHandlers(gameController) + configurePSVR2IfNeeded(controller) Logger.log(message: "Game Controller \(controller.vendorName ?? "unknown vendor") connected and Configured") } @objc private func controllerDidDisconnect(_ note: Notification) { guard let controller = note.object as? GCController else { return } if currentGameController === controller.extendedGamepad { currentGameController = nil } + clearPSVR2IfNeeded(controller) Logger.log(message: "Game Controller \(controller.vendorName ?? "unknown vendor") disconnected") } diff --git a/Sources/UntoldEngine/Systems/RenderGraphOptimizationReport.swift b/Sources/UntoldEngine/Systems/RenderGraphOptimizationReport.swift new file mode 100644 index 00000000..50e7d647 --- /dev/null +++ b/Sources/UntoldEngine/Systems/RenderGraphOptimizationReport.swift @@ -0,0 +1,344 @@ +// +// RenderGraphOptimizationReport.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +struct RenderGraphResourceDeclarationSnapshot: Equatable { + let kind: RenderExtensionArtifactKind + let resourceID: String + let ownerID: String? + let lifetime: RenderExtensionResourceLifetime +} + +struct CompiledRenderGraphRedundantDependency: Equatable { + let passID: String + let dependencyID: String + let inferred: Bool +} + +enum CompiledRenderGraphOptimizationIssue: Equatable { + case missingResourceInterval( + passID: String, + kind: RenderExtensionArtifactKind, + resourceID: String + ) + case staleResourceInterval(kind: RenderExtensionArtifactKind, resourceID: String) + case resourceIntervalMismatch(kind: RenderExtensionArtifactKind, resourceID: String) + case missingAliasSlot(kind: RenderExtensionArtifactKind, resourceID: String, slotID: Int) + case invalidAliasSlotResource(kind: RenderExtensionArtifactKind, resourceID: String) + case incompatibleAliasSlot(slotID: Int, resourceID: String) + case overlappingAliasSlot( + slotID: Int, + firstResourceID: String, + secondResourceID: String + ) +} + +struct CompiledRenderGraphStatistics: Equatable { + let passCount: Int + let dependencyCount: Int + let explicitDependencyCount: Int + let inferredDependencyCount: Int + let usedResourceCount: Int + let persistentResourceCount: Int + let transientResourceCount: Int + let transientAliasSlotCount: Int + let aliasedResourceCount: Int + let backingStoreReductionOpportunityCount: Int + let unusedDeclaredResourceCount: Int + let redundantDependencyCount: Int +} + +struct CompiledRenderGraphOptimizationReport: Equatable { + let statistics: CompiledRenderGraphStatistics + let redundantDependencies: [CompiledRenderGraphRedundantDependency] + let unusedResources: [RenderGraphResourceDeclarationSnapshot] + let resourcePlanIssues: [CompiledRenderGraphOptimizationIssue] + + var isResourcePlanValid: Bool { + resourcePlanIssues.isEmpty + } +} + +private struct OptimizationResourceKey: Hashable, Comparable { + let kind: RenderExtensionArtifactKind + let id: String + + static func < (lhs: OptimizationResourceKey, rhs: OptimizationResourceKey) -> Bool { + if lhs.kind.rawValue != rhs.kind.rawValue { + return lhs.kind.rawValue < rhs.kind.rawValue + } + return lhs.id < rhs.id + } +} + +private struct ExpectedResourceInterval { + var firstUsePassIndex: Int + var lastUsePassIndex: Int + var passIDs: [String] +} + +func compileRenderGraphOptimizationReport( + _ orderedPasses: [CompiledRenderGraphPass], + resourcePlan: CompiledRenderGraphResourcePlan +) -> CompiledRenderGraphOptimizationReport { + let redundantDependencies = findRedundantRenderGraphDependencies(orderedPasses) + let usedKeys = Set(resourcePlan.resources.map { + OptimizationResourceKey(kind: $0.kind, id: $0.resourceID) + }) + let unusedResources = RenderResourceRegistry.shared.declarationSnapshot().filter { + !usedKeys.contains(OptimizationResourceKey(kind: $0.kind, id: $0.resourceID)) + } + let resourcePlanIssues = validateCompiledRenderGraphResourcePlan( + orderedPasses, + resourcePlan: resourcePlan + ) + let dependencyCount = orderedPasses.reduce(0) { $0 + $1.dependencies.count } + let inferredDependencyCount = orderedPasses.reduce(0) { + $0 + $1.inferredDependencies.count + } + let aliasedResourceCount = resourcePlan.transientAliasSlots.reduce(0) { count, slot in + count + (slot.resourceIDs.count > 1 ? slot.resourceIDs.count : 0) + } + let backingStoreReduction = resourcePlan.transientAliasSlots.reduce(0) { count, slot in + count + max(0, slot.resourceIDs.count - 1) + } + + return CompiledRenderGraphOptimizationReport( + statistics: CompiledRenderGraphStatistics( + passCount: orderedPasses.count, + dependencyCount: dependencyCount, + explicitDependencyCount: dependencyCount - inferredDependencyCount, + inferredDependencyCount: inferredDependencyCount, + usedResourceCount: resourcePlan.resources.count, + persistentResourceCount: resourcePlan.resources.filter { + $0.lifetime == .persistent + }.count, + transientResourceCount: resourcePlan.resources.filter { + $0.lifetime == .transient + }.count, + transientAliasSlotCount: resourcePlan.transientAliasSlots.count, + aliasedResourceCount: aliasedResourceCount, + backingStoreReductionOpportunityCount: backingStoreReduction, + unusedDeclaredResourceCount: unusedResources.count, + redundantDependencyCount: redundantDependencies.count + ), + redundantDependencies: redundantDependencies, + unusedResources: unusedResources, + resourcePlanIssues: resourcePlanIssues + ) +} + +func validateCompiledRenderGraphResourcePlan( + _ orderedPasses: [CompiledRenderGraphPass], + resourcePlan: CompiledRenderGraphResourcePlan +) -> [CompiledRenderGraphOptimizationIssue] { + let expectedIntervals = expectedResourceIntervals(orderedPasses) + var resourcesByKey: [OptimizationResourceKey: CompiledRenderGraphResource] = [:] + for resource in resourcePlan.resources { + resourcesByKey[ + OptimizationResourceKey(kind: resource.kind, id: resource.resourceID) + ] = resource + } + var issues: [CompiledRenderGraphOptimizationIssue] = [] + + for key in expectedIntervals.keys.sorted() { + guard let expected = expectedIntervals[key] else { continue } + guard let resource = resourcesByKey[key] else { + let passID = expected.passIDs.first ?? "" + issues.append( + .missingResourceInterval( + passID: passID, + kind: key.kind, + resourceID: key.id + ) + ) + continue + } + if resource.firstUsePassIndex != expected.firstUsePassIndex + || resource.lastUsePassIndex != expected.lastUsePassIndex + || resource.passIDs != expected.passIDs + { + issues.append(.resourceIntervalMismatch(kind: key.kind, resourceID: key.id)) + } + } + for key in resourcesByKey.keys.sorted() where expectedIntervals[key] == nil { + issues.append(.staleResourceInterval(kind: key.kind, resourceID: key.id)) + } + + var slotsByID: [Int: CompiledRenderGraphAliasSlot] = [:] + for slot in resourcePlan.transientAliasSlots { + slotsByID[slot.id] = slot + } + for resource in resourcePlan.resources { + guard let slotID = resource.aliasSlotID else { + if resource.lifetime == .transient { + issues.append( + .invalidAliasSlotResource( + kind: resource.kind, + resourceID: resource.resourceID + ) + ) + } + continue + } + guard let slot = slotsByID[slotID] else { + issues.append( + .missingAliasSlot( + kind: resource.kind, + resourceID: resource.resourceID, + slotID: slotID + ) + ) + continue + } + if resource.lifetime != .transient + || slot.kind != resource.kind + || slot.ownerID != resource.ownerID + || !slot.resourceIDs.contains(resource.resourceID) + { + issues.append( + .invalidAliasSlotResource( + kind: resource.kind, + resourceID: resource.resourceID + ) + ) + } + } + + for slot in resourcePlan.transientAliasSlots { + let slotResources = slot.resourceIDs.compactMap { resourceID in + resourcesByKey[OptimizationResourceKey(kind: slot.kind, id: resourceID)] + }.sorted { lhs, rhs in + if lhs.firstUsePassIndex != rhs.firstUsePassIndex { + return lhs.firstUsePassIndex < rhs.firstUsePassIndex + } + return lhs.resourceID < rhs.resourceID + } + var compatibility: RenderGraphTransientResourceCompatibility? + for resourceID in slot.resourceIDs { + let key = OptimizationResourceKey(kind: slot.kind, id: resourceID) + guard let resource = resourcesByKey[key], resource.aliasSlotID == slot.id else { + issues.append( + .invalidAliasSlotResource(kind: slot.kind, resourceID: resourceID) + ) + continue + } + guard let resourceCompatibility = optimizationCompatibility(for: key) else { + issues.append( + .incompatibleAliasSlot(slotID: slot.id, resourceID: resourceID) + ) + continue + } + if let compatibility, compatibility != resourceCompatibility { + issues.append( + .incompatibleAliasSlot(slotID: slot.id, resourceID: resourceID) + ) + } else { + compatibility = resourceCompatibility + } + } + if slotResources.count > 1 { + for index in 1 ..< slotResources.count { + let previous = slotResources[index - 1] + let current = slotResources[index] + if previous.lastUsePassIndex >= current.firstUsePassIndex { + issues.append( + .overlappingAliasSlot( + slotID: slot.id, + firstResourceID: previous.resourceID, + secondResourceID: current.resourceID + ) + ) + } + } + } + } + return issues +} + +private func expectedResourceIntervals( + _ orderedPasses: [CompiledRenderGraphPass] +) -> [OptimizationResourceKey: ExpectedResourceInterval] { + var intervals: [OptimizationResourceKey: ExpectedResourceInterval] = [:] + for (passIndex, pass) in orderedPasses.enumerated() { + var keys: Set = [] + for usage in pass.resourceUsages { + switch usage { + case let .texture(id, _): + keys.insert(OptimizationResourceKey(kind: .texture, id: id.rawValue)) + case let .buffer(id, _): + keys.insert(OptimizationResourceKey(kind: .buffer, id: id.rawValue)) + } + } + for key in keys { + if var interval = intervals[key] { + interval.lastUsePassIndex = passIndex + interval.passIDs.append(pass.id) + intervals[key] = interval + } else { + intervals[key] = ExpectedResourceInterval( + firstUsePassIndex: passIndex, + lastUsePassIndex: passIndex, + passIDs: [pass.id] + ) + } + } + } + return intervals +} + +private func findRedundantRenderGraphDependencies( + _ orderedPasses: [CompiledRenderGraphPass] +) -> [CompiledRenderGraphRedundantDependency] { + let passesByID = Dictionary(uniqueKeysWithValues: orderedPasses.map { ($0.id, $0) }) + var redundant: [CompiledRenderGraphRedundantDependency] = [] + for pass in orderedPasses { + for dependencyID in Set(pass.dependencies).sorted() { + let alternateRoots = pass.dependencies.filter { $0 != dependencyID } + let duplicateCount = pass.dependencies.filter { $0 == dependencyID }.count + let hasAlternatePath = duplicateCount > 1 || alternateRoots.contains { rootID in + compiledPass(rootID, dependsOn: dependencyID, passesByID: passesByID) + } + if hasAlternatePath { + redundant.append( + CompiledRenderGraphRedundantDependency( + passID: pass.id, + dependencyID: dependencyID, + inferred: pass.inferredDependencies.contains(dependencyID) + ) + ) + } + } + } + return redundant +} + +private func compiledPass( + _ passID: String, + dependsOn dependencyID: String, + passesByID: [String: CompiledRenderGraphPass] +) -> Bool { + var pending = passesByID[passID]?.dependencies ?? [] + var visited: Set = [] + while let current = pending.popLast() { + if current == dependencyID { return true } + guard visited.insert(current).inserted else { continue } + pending.append(contentsOf: passesByID[current]?.dependencies ?? []) + } + return false +} + +private func optimizationCompatibility( + for key: OptimizationResourceKey +) -> RenderGraphTransientResourceCompatibility? { + renderGraphPlannedResourceDeclaration( + kind: key.kind, + resourceID: key.id + )?.compatibility +} diff --git a/Sources/UntoldEngine/Systems/RenderGraphResourcePlan.swift b/Sources/UntoldEngine/Systems/RenderGraphResourcePlan.swift new file mode 100644 index 00000000..d12acb58 --- /dev/null +++ b/Sources/UntoldEngine/Systems/RenderGraphResourcePlan.swift @@ -0,0 +1,245 @@ +// +// RenderGraphResourcePlan.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal + +struct CompiledRenderGraphResource: Equatable { + let kind: RenderExtensionArtifactKind + let resourceID: String + let ownerID: String? + let lifetime: RenderExtensionResourceLifetime + let firstUsePassIndex: Int + let lastUsePassIndex: Int + let passIDs: [String] + let aliasSlotID: Int? +} + +struct CompiledRenderGraphAliasSlot: Equatable { + let id: Int + let kind: RenderExtensionArtifactKind + let ownerID: String? + let resourceIDs: [String] +} + +struct CompiledRenderGraphResourcePlan: Equatable { + let resources: [CompiledRenderGraphResource] + let transientAliasSlots: [CompiledRenderGraphAliasSlot] + + func resource( + kind: RenderExtensionArtifactKind, + id: String + ) -> CompiledRenderGraphResource? { + resources.first { $0.kind == kind && $0.resourceID == id } + } +} + +private struct PlannedResourceKey: Hashable, Comparable { + let kind: RenderExtensionArtifactKind + let id: String + + static func < (lhs: PlannedResourceKey, rhs: PlannedResourceKey) -> Bool { + if lhs.kind.rawValue != rhs.kind.rawValue { + return lhs.kind.rawValue < rhs.kind.rawValue + } + return lhs.id < rhs.id + } +} + +enum RenderGraphTransientResourceCompatibility: Equatable { + case texture( + ownerID: String?, + size: RenderExtensionResourceSize, + pixelFormat: MTLPixelFormat, + usage: MTLTextureUsage, + storageMode: MTLStorageMode, + mipMapLevels: Int, + sampleCount: Int + ) + case buffer( + ownerID: String?, + length: Int, + options: MTLResourceOptions + ) +} + +struct RenderGraphPlannedResourceDeclaration { + let ownerID: String? + let lifetime: RenderExtensionResourceLifetime + let compatibility: RenderGraphTransientResourceCompatibility +} + +private struct MutableResourceInterval { + let key: PlannedResourceKey + let ownerID: String? + let lifetime: RenderExtensionResourceLifetime + let compatibility: RenderGraphTransientResourceCompatibility? + var firstUsePassIndex: Int + var lastUsePassIndex: Int + var passIDs: [String] +} + +private struct MutableAliasSlot { + let id: Int + let kind: RenderExtensionArtifactKind + let ownerID: String? + let compatibility: RenderGraphTransientResourceCompatibility + var lastUsePassIndex: Int + var resourceIDs: [String] +} + +func compileRenderGraphResourcePlan( + _ orderedPasses: [CompiledRenderGraphPass] +) -> CompiledRenderGraphResourcePlan { + var intervals: [PlannedResourceKey: MutableResourceInterval] = [:] + + for (passIndex, pass) in orderedPasses.enumerated() { + var passResources: Set = [] + for usage in pass.resourceUsages { + let key: PlannedResourceKey + switch usage { + case let .texture(id, _): + key = PlannedResourceKey(kind: .texture, id: id.rawValue) + case let .buffer(id, _): + key = PlannedResourceKey(kind: .buffer, id: id.rawValue) + } + passResources.insert(key) + } + + for key in passResources.sorted() { + if var interval = intervals[key] { + interval.lastUsePassIndex = passIndex + interval.passIDs.append(pass.id) + intervals[key] = interval + } else if let declaration = plannedResourceDeclaration(for: key) { + intervals[key] = MutableResourceInterval( + key: key, + ownerID: declaration.ownerID, + lifetime: declaration.lifetime, + compatibility: declaration.compatibility, + firstUsePassIndex: passIndex, + lastUsePassIndex: passIndex, + passIDs: [pass.id] + ) + } + } + } + + let orderedIntervals = intervals.values.sorted { lhs, rhs in + if lhs.firstUsePassIndex != rhs.firstUsePassIndex { + return lhs.firstUsePassIndex < rhs.firstUsePassIndex + } + return lhs.key < rhs.key + } + var aliasSlots: [MutableAliasSlot] = [] + var aliasSlotByResource: [PlannedResourceKey: Int] = [:] + + // Stable first-fit assignment keeps slot IDs reproducible across compilations. + for interval in orderedIntervals where interval.lifetime == .transient { + guard let compatibility = interval.compatibility else { continue } + if let slotIndex = aliasSlots.firstIndex(where: { slot in + slot.compatibility == compatibility + && slot.lastUsePassIndex < interval.firstUsePassIndex + }) { + aliasSlots[slotIndex].lastUsePassIndex = interval.lastUsePassIndex + aliasSlots[slotIndex].resourceIDs.append(interval.key.id) + aliasSlotByResource[interval.key] = aliasSlots[slotIndex].id + } else { + let slotID = aliasSlots.count + aliasSlots.append( + MutableAliasSlot( + id: slotID, + kind: interval.key.kind, + ownerID: interval.ownerID, + compatibility: compatibility, + lastUsePassIndex: interval.lastUsePassIndex, + resourceIDs: [interval.key.id] + ) + ) + aliasSlotByResource[interval.key] = slotID + } + } + + let resources = intervals.values.sorted { $0.key < $1.key }.map { interval in + CompiledRenderGraphResource( + kind: interval.key.kind, + resourceID: interval.key.id, + ownerID: interval.ownerID, + lifetime: interval.lifetime, + firstUsePassIndex: interval.firstUsePassIndex, + lastUsePassIndex: interval.lastUsePassIndex, + passIDs: interval.passIDs, + aliasSlotID: aliasSlotByResource[interval.key] + ) + } + let compiledSlots = aliasSlots.map { slot in + CompiledRenderGraphAliasSlot( + id: slot.id, + kind: slot.kind, + ownerID: slot.ownerID, + resourceIDs: slot.resourceIDs + ) + } + return CompiledRenderGraphResourcePlan( + resources: resources, + transientAliasSlots: compiledSlots + ) +} + +private func plannedResourceDeclaration( + for key: PlannedResourceKey +) -> RenderGraphPlannedResourceDeclaration? { + renderGraphPlannedResourceDeclaration(kind: key.kind, resourceID: key.id) +} + +func renderGraphPlannedResourceDeclaration( + kind: RenderExtensionArtifactKind, + resourceID: String +) -> RenderGraphPlannedResourceDeclaration? { + switch kind { + case .texture: + guard let declaration = RenderResourceRegistry.shared.textureDeclaration( + RenderTextureResourceID(resourceID) + ) else { + return nil + } + let descriptor = declaration.descriptor + return RenderGraphPlannedResourceDeclaration( + ownerID: declaration.ownerID, + lifetime: descriptor.lifetime, + compatibility: .texture( + ownerID: declaration.ownerID, + size: descriptor.size, + pixelFormat: descriptor.pixelFormat, + usage: descriptor.usage, + storageMode: descriptor.storageMode, + mipMapLevels: descriptor.mipMapLevels, + sampleCount: descriptor.sampleCount + ) + ) + case .buffer: + guard let declaration = RenderResourceRegistry.shared.bufferDeclaration( + RenderBufferResourceID(resourceID) + ) else { + return nil + } + let descriptor = declaration.descriptor + return RenderGraphPlannedResourceDeclaration( + ownerID: declaration.ownerID, + lifetime: descriptor.lifetime, + compatibility: .buffer( + ownerID: declaration.ownerID, + length: descriptor.length, + options: descriptor.options + ) + ) + default: + return nil + } +} diff --git a/Sources/UntoldEngine/Systems/RenderingSystem.swift b/Sources/UntoldEngine/Systems/RenderingSystem.swift index 4f39eca5..2114d7a8 100644 --- a/Sources/UntoldEngine/Systems/RenderingSystem.swift +++ b/Sources/UntoldEngine/Systems/RenderingSystem.swift @@ -86,30 +86,29 @@ func UpdateRenderingSystem(in view: MTKView) { commandBuffer.label = "Rendering Command Buffer" - // build a render graph - let (graph, _) = buildGameModeGraph() - - // sorted it - let sortedPasses = try! topologicalSortGraph(graph: graph) - - // execute it - #if ENGINE_STATS_ENABLED - let encodeStart = CACurrentMediaTime() - #endif - EngineProfiler.shared.beginScope(.encode) - executeGraph(graph, sortedPasses, commandBuffer) - // Temporal HZB schedule: - // 1) current frame renders depth - // 2) build HZB from that depth - // 3) next frame culling consumes HZB - buildHZBDepthPyramid(commandBuffer) - EngineProfiler.shared.endScope(.encode) - #if ENGINE_STATS_ENABLED - let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 - EngineStatsMonitor.shared.update { snapshot in - snapshot.timing.encodeMs += encodeMs - } - #endif + do { + let graph = try buildExecutableGameModeGraph() + + #if ENGINE_STATS_ENABLED + let encodeStart = CACurrentMediaTime() + #endif + EngineProfiler.shared.beginScope(.encode) + executeGraph(graph, commandBuffer) + // Temporal HZB schedule: + // 1) current frame renders depth + // 2) build HZB from that depth + // 3) next frame culling consumes HZB + buildHZBDepthPyramid(commandBuffer) + EngineProfiler.shared.endScope(.encode) + #if ENGINE_STATS_ENABLED + let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.encodeMs += encodeMs + } + #endif + } catch { + Logger.logError(message: "[RenderGraph] Skipping frame: \(error)") + } } if let drawable = view.currentDrawable { @@ -161,33 +160,32 @@ func UpdateXRRenderingSystem(commandBuffer: MTLCommandBuffer, passDescriptor: MT commandBuffer.label = "XR Rendering Command Buffer" - // build a render graph - let (graph, _) = buildGameModeGraph() + do { + let graph = try buildExecutableGameModeGraph() - // sorted it - let sortedPasses = try! topologicalSortGraph(graph: graph) - - // execute it - #if ENGINE_STATS_ENABLED - let encodeStart = shouldRecordStatsInThisCallback ? CACurrentMediaTime() : 0.0 - #endif - executeGraph(graph, sortedPasses, commandBuffer) + #if ENGINE_STATS_ENABLED + let encodeStart = shouldRecordStatsInThisCallback ? CACurrentMediaTime() : 0.0 + #endif + executeGraph(graph, commandBuffer) - // AR path renders a single eye through this callback, so build HZB here. - // XR stereo path builds once after both eyes in UntoldEngineXR.executeXRSystemPass. - if renderInfo.immersionStyle == .ar { - buildHZBDepthPyramid(commandBuffer) - } - #if ENGINE_STATS_ENABLED - if shouldRecordStatsInThisCallback { - let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 - let renderTotalMs = (CACurrentMediaTime() - renderTotalStart) * 1000.0 - EngineStatsMonitor.shared.update { snapshot in - snapshot.timing.encodeMs += encodeMs - snapshot.timing.renderTotalMs += renderTotalMs - } + // AR path renders a single eye through this callback, so build HZB here. + // XR stereo path builds once after both eyes in UntoldEngineXR.executeXRSystemPass. + if renderInfo.immersionStyle == .ar { + buildHZBDepthPyramid(commandBuffer) } - #endif + #if ENGINE_STATS_ENABLED + if shouldRecordStatsInThisCallback { + let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 + let renderTotalMs = (CACurrentMediaTime() - renderTotalStart) * 1000.0 + EngineStatsMonitor.shared.update { snapshot in + snapshot.timing.encodeMs += encodeMs + snapshot.timing.renderTotalMs += renderTotalMs + } + } + #endif + } catch { + Logger.logError(message: "[RenderGraph] Skipping XR frame: \(error)") + } // Note: Semaphore signaling is handled by executeXRSystemPass completion handler let visibleEntityIdsAtSubmission = visibleEntityIds @@ -205,7 +203,52 @@ func UpdateXRRenderingSystem(commandBuffer: MTLCommandBuffer, passDescriptor: MT // graphs -public typealias RenderGraphResult = (graph: [String: RenderPass], finalPassID: String) +typealias RenderGraphResult = (graph: [String: RenderPass], finalPassID: String) +private typealias CompiledRenderGraphResult = ( + graph: [String: RenderPass], + finalPassID: String, + compiledGraph: CompiledRenderGraph +) + +let gameModeReservedPassIDs: Set = [ + "environment", + "grid", + "shadow", + "batchedShadow", + "model", + "batchedModel", + "hzbDepthSource", + "ssao", + "lightPass", + "transparency", + "wireframe", + "spatialDebug", + "gaussian", + "postProcessBypass", + "postProcessDisabledBypass", + "depthOfField", + "chromatic", + "bloomThreshold", + "blur_pass_hor_pass1", + "blur_pass_ver_pass1", + "blur_pass_hor_pass2", + "blur_pass_ver_pass2", + "blur_pass_hor_pass3", + "blur_pass_ver_pass3", + "blur_pass_hor_pass4", + "blur_pass_ver_pass4", + "bloomComposite", + "vignette", + "precomp", + "look", + "fxaa", + "fxaaEdgeDebug", + "smaaEdges", + "smaaBlendWeights", + "smaaNeighborhood", + "smaaDifference", + "outputTransform", +] enum BasePassMode { case environment @@ -236,11 +279,13 @@ func addSceneBackgroundPass( } } -public func buildGameModeGraph() -> RenderGraphResult { +private func buildGameModeGraphWithCompilation() throws -> CompiledRenderGraphResult { updateGBufferStorageForCurrentDebugMode() updateOpaqueSampleCountForCurrentState() - var graph = [String: RenderPass]() + var builder = RenderGraphBuilder(reservedPassIDs: gameModeReservedPassIDs) + let buildContext = makeRenderGraphBuildContext() + RenderExtensionRegistry.shared.buildGraph(&builder, context: buildContext) // Determine base pass mode based on immersion style let mode: BasePassMode @@ -260,36 +305,53 @@ public func buildGameModeGraph() -> RenderGraphResult { mode = renderEnvironment ? .environment : .grid } - let basePassID = addSceneBackgroundPass(to: &graph, mode: mode) + var backgroundGraph: [String: RenderPass] = [:] + let basePassID = addSceneBackgroundPass(to: &backgroundGraph, mode: mode) + for passID in backgroundGraph.keys.sorted() { + if let pass = backgroundGraph[passID] { + builder.addPass(pass) + } + } let shadowDependency = basePassID.map { [$0] } ?? [] let shadowPass = RenderPass( id: "shadow", dependencies: shadowDependency, execute: RenderPasses.shadowExecution ) - graph[shadowPass.id] = shadowPass + builder.addPass(shadowPass) // Add batched shadow pass (runs after regular shadow pass) let batchedShadowPass = RenderPass( id: "batchedShadow", dependencies: [shadowPass.id], execute: RenderPasses.batchedShadowExecution ) - graph[batchedShadowPass.id] = batchedShadowPass + builder.addPass(batchedShadowPass) - gBufferPass(graph: &graph, shadowPass: batchedShadowPass) + var opaqueGraph: [String: RenderPass] = [:] + gBufferPass(graph: &opaqueGraph, shadowPass: batchedShadowPass) + for passID in opaqueGraph.keys.sorted() { + if let pass = opaqueGraph[passID] { + builder.addPass(pass) + } + } + + let afterOpaqueLightingID = builder.resolveStage(.afterOpaqueLighting, after: "lightPass") ?? "lightPass" + let beforeTransparencyID = builder.resolveStage(.beforeTransparency, after: afterOpaqueLightingID) ?? afterOpaqueLightingID // Transparent forward pass after deferred lighting. let transparencyPass = RenderPass( id: "transparency", - dependencies: ["lightPass"], + dependencies: [beforeTransparencyID], execute: RenderPasses.transparencyExecution ) - graph[transparencyPass.id] = transparencyPass + builder.addPass(transparencyPass) + + let afterTransparencyID = builder.resolveStage(.afterTransparency, after: transparencyPass.id) ?? transparencyPass.id let wireframePass = RenderPass( id: "wireframe", - dependencies: [transparencyPass.id], + dependencies: [afterTransparencyID], execute: RenderPasses.wireframeExecution ) - graph[wireframePass.id] = wireframePass + builder.addPass(wireframePass) // Spatial debug overlays are rendered on top of lit scene color. let spatialDebugPass = RenderPass( @@ -297,17 +359,19 @@ public func buildGameModeGraph() -> RenderGraphResult { dependencies: [wireframePass.id], execute: RenderPasses.spatialDebugBoundsExecution ) - graph[spatialDebugPass.id] = spatialDebugPass + builder.addPass(spatialDebugPass) // Gaussian pass depends on model pass - needs depth buffer from 3D models let gaussianPass = RenderPass(id: "gaussian", dependencies: ["model"], execute: RenderPasses.gaussianExecution) - graph[gaussianPass.id] = gaussianPass + builder.addPass(gaussianPass) + + let beforePostProcessID = builder.resolveStage(.beforePostProcess, after: spatialDebugPass.id) ?? spatialDebugPass.id let postProcessID: String if bypassPostProcessing { let bypassPass = RenderPass( id: "postProcessBypass", - dependencies: [spatialDebugPass.id], + dependencies: [beforePostProcessID], execute: { _ in guard let deferredDescriptor = renderInfo.deferredRenderPassDescriptor else { return @@ -316,110 +380,146 @@ public func buildGameModeGraph() -> RenderGraphResult { deferredDescriptor.colorAttachments[0].texture } ) - graph[bypassPass.id] = bypassPass + builder.addPass(bypassPass) postProcessID = bypassPass.id } else { - let postProcess = postProcessingEffects(graph: &graph, deferredPassId: spatialDebugPass.id) + var postProcessGraph: [String: RenderPass] = [:] + let postProcess = postProcessingEffects( + graph: &postProcessGraph, + deferredPassId: beforePostProcessID + ) + for passID in postProcessGraph.keys.sorted() { + if let pass = postProcessGraph[passID] { + builder.addPass(pass) + } + } postProcessID = postProcess.id } + let afterPostProcessID = builder.resolveStage(.afterPostProcess, after: postProcessID) ?? postProcessID + let beforeCompositeID = builder.resolveStage(.beforeComposite, after: afterPostProcessID) ?? afterPostProcessID + // PreComposite depends on both post-processing and gaussian let preCompPass = RenderPass( id: "precomp", - dependencies: [postProcessID, gaussianPass.id], + dependencies: [beforeCompositeID, gaussianPass.id], execute: RenderPasses.preCompositeExecution ) - graph[preCompPass.id] = preCompPass + builder.addPass(preCompPass) + + let beforeLookID = builder.resolveStage(.beforeLook, after: preCompPass.id) ?? preCompPass.id let lookPass = RenderPass( id: "look", - dependencies: [preCompPass.id], + dependencies: [beforeLookID], execute: lookRenderPass ) - graph[lookPass.id] = lookPass + builder.addPass(lookPass) let outputDependency: String if renderDebugViewMode == .fxaaEdgeDebug { let fxaaEdgeDebugPass = RenderPass(id: "fxaaEdgeDebug", dependencies: [lookPass.id], execute: fxaaEdgeDebugRenderPass) - graph[fxaaEdgeDebugPass.id] = fxaaEdgeDebugPass + builder.addPass(fxaaEdgeDebugPass) outputDependency = fxaaEdgeDebugPass.id } else if renderDebugViewMode == .smaaEdges { let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass + builder.addPass(smaaEdgesPass) outputDependency = smaaEdgesPass.id } else if renderDebugViewMode == .smaaBlend { let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass + builder.addPass(smaaEdgesPass) let smaaBlendWeightsPass = RenderPass( id: "smaaBlendWeights", dependencies: [smaaEdgesPass.id], execute: smaaBlendWeightsRenderPass ) - graph[smaaBlendWeightsPass.id] = smaaBlendWeightsPass + builder.addPass(smaaBlendWeightsPass) outputDependency = smaaBlendWeightsPass.id } else if renderDebugViewMode == .smaaDifference { let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass + builder.addPass(smaaEdgesPass) let smaaBlendWeightsPass = RenderPass( id: "smaaBlendWeights", dependencies: [smaaEdgesPass.id], execute: smaaBlendWeightsRenderPass ) - graph[smaaBlendWeightsPass.id] = smaaBlendWeightsPass + builder.addPass(smaaBlendWeightsPass) let smaaNeighborhoodPass = RenderPass( id: "smaaNeighborhood", dependencies: [smaaBlendWeightsPass.id], execute: smaaNeighborhoodRenderPass ) - graph[smaaNeighborhoodPass.id] = smaaNeighborhoodPass + builder.addPass(smaaNeighborhoodPass) let smaaDifferencePass = RenderPass( id: "smaaDifference", dependencies: [smaaNeighborhoodPass.id], execute: smaaDifferenceRenderPass ) - graph[smaaDifferencePass.id] = smaaDifferencePass + builder.addPass(smaaDifferencePass) outputDependency = smaaDifferencePass.id } else { switch antiAliasingMode { case .fxaa: let fxaaPass = RenderPass(id: "fxaa", dependencies: [lookPass.id], execute: fxaaRenderPass) - graph[fxaaPass.id] = fxaaPass + builder.addPass(fxaaPass) outputDependency = fxaaPass.id case .smaa: let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass + builder.addPass(smaaEdgesPass) let smaaBlendWeightsPass = RenderPass( id: "smaaBlendWeights", dependencies: [smaaEdgesPass.id], execute: smaaBlendWeightsRenderPass ) - graph[smaaBlendWeightsPass.id] = smaaBlendWeightsPass + builder.addPass(smaaBlendWeightsPass) let smaaNeighborhoodPass = RenderPass( id: "smaaNeighborhood", dependencies: [smaaBlendWeightsPass.id], execute: smaaNeighborhoodRenderPass ) - graph[smaaNeighborhoodPass.id] = smaaNeighborhoodPass + builder.addPass(smaaNeighborhoodPass) outputDependency = smaaNeighborhoodPass.id case .none, .msaa: outputDependency = lookPass.id } } + let beforeOutputID = builder.resolveStage(.beforeOutput, after: outputDependency) ?? outputDependency + let outputPass = RenderPass( id: "outputTransform", - dependencies: [outputDependency], + dependencies: [beforeOutputID], execute: outputTransformRenderPass ) - graph[outputPass.id] = outputPass + builder.addPass(outputPass) + + let analysis = try builder.analyzeForCompilation() + if let compiledGraph = analysis.compiledGraph { + return (analysis.scheduledGraph, outputPass.id, compiledGraph) + } + + let removedExtensionIDs = RenderExtensionRegistry.shared.rejectGraphValidationFailures( + analysis.validationReport + ) + if !removedExtensionIDs.isEmpty { + return try buildGameModeGraphWithCompilation() + } + throw analysis.validationReport.errors[0] +} + +func buildGameModeGraph() throws -> RenderGraphResult { + let result = try buildGameModeGraphWithCompilation() + return (result.graph, result.finalPassID) +} - return (graph, outputPass.id) +func buildExecutableGameModeGraph() throws -> CompiledRenderGraph { + try buildGameModeGraphWithCompilation().compiledGraph } /// G-Buffer Pass (TBDR) @@ -557,7 +657,7 @@ func postProcessingEffects(graph: inout [String: RenderPass], deferredPassId: St } /// Gaussian render graph -public func buildGaussianGraph() -> RenderGraphResult { +func buildGaussianGraph() -> RenderGraphResult { var graph = [String: RenderPass]() let gaussianPass = RenderPass(id: "gaussian", dependencies: [], execute: RenderPasses.gaussianExecution) diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 92080fee..c2f98d94 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation +import Metal import simd public enum LODFadeTransitionSetting: Sendable { @@ -57,6 +58,7 @@ public enum RenderingProperty: Sendable { case postProcessing(RenderingToggle) case wireframe(WireframeProperty) case environment(RenderingEnvironmentProperty) + case extensions(RenderExtensionProperty) } public enum RenderingToggle: Sendable { @@ -64,6 +66,12 @@ public enum RenderingToggle: Sendable { case disabled } +public enum RenderExtensionProperty: Sendable { + case register(any RenderExtension) + case unregister(String) + case removeAll +} + public enum RenderingEnvironmentProperty: Sendable { case ibl(Bool) case visible(Bool) @@ -91,6 +99,38 @@ public func setRendering(_ property: RenderingProperty) { applyWireframeProperty(property) case let .environment(property): applyRenderingEnvironmentProperty(property) + case let .extensions(property): + applyRenderExtensionProperty(property) + } +} + +public enum RenderResourceQuery { + case texture(String) +} + +public func getRenderResource(_ query: RenderResourceQuery) -> MTLTexture? { + switch query { + case let .texture(id): + RenderResourceRegistry.shared.texture(id) + } +} + +public func getRenderResource(_ id: RenderTextureResourceID) -> MTLTexture? { + RenderResourceRegistry.shared.texture(id) +} + +public func getRenderResource(_ id: RenderBufferResourceID) -> MTLBuffer? { + RenderResourceRegistry.shared.buffer(id) +} + +private func applyRenderExtensionProperty(_ property: RenderExtensionProperty) { + switch property { + case let .register(renderExtension): + RenderExtensionRegistry.shared.register(renderExtension) + case let .unregister(id): + RenderExtensionRegistry.shared.unregister(id: id) + case .removeAll: + RenderExtensionRegistry.shared.removeAll() } } diff --git a/Sources/UntoldEngine/Utils/Globals.swift b/Sources/UntoldEngine/Utils/Globals.swift index 2da5c6a9..e522dca9 100644 --- a/Sources/UntoldEngine/Utils/Globals.swift +++ b/Sources/UntoldEngine/Utils/Globals.swift @@ -1838,7 +1838,7 @@ public final class SSAOParams: ObservableObject, @unchecked Sendable { public var avgBlurTime: Double = 0.0 } -public enum UntoldImmersionMode { +public enum UntoldImmersionMode: Sendable { case none case mixed case full diff --git a/Sources/UntoldEngine/Utils/InputSystemAPI.swift b/Sources/UntoldEngine/Utils/InputSystemAPI.swift index ea28c9d2..bbf5ce5e 100644 --- a/Sources/UntoldEngine/Utils/InputSystemAPI.swift +++ b/Sources/UntoldEngine/Utils/InputSystemAPI.swift @@ -19,14 +19,23 @@ public enum XRInputProperty: Sendable { case sceneReady(Bool) } +public enum PSVR2InputProperty: Sendable { + case motionEnabled(Bool) + case leftTriggerEffect(PSVR2TriggerEffect) + case rightTriggerEffect(PSVR2TriggerEffect) +} + public enum InputProperty: Sendable { case xr(XRInputProperty) + case psvr2(PSVR2InputProperty) } public func setInput(_ property: InputProperty) { switch property { case let .xr(xrProperty): applyXRInputProperty(xrProperty) + case let .psvr2(psvr2Property): + applyPSVR2InputProperty(psvr2Property) } } @@ -41,6 +50,18 @@ private func applyXRInputProperty(_ property: XRInputProperty) { } } +private func applyPSVR2InputProperty(_ property: PSVR2InputProperty) { + let input = InputSystem.shared + switch property { + case let .motionEnabled(enabled): + input.psvr2MotionEnabled = enabled + case let .leftTriggerEffect(effect): + input.setLeftTriggerEffect(effect) + case let .rightTriggerEffect(effect): + input.setRightTriggerEffect(effect) + } +} + // MARK: - Input Actions public func registerXREvents() { @@ -51,6 +72,30 @@ public func unregisterXREvents() { InputSystem.shared.unregisterXREvents() } +public func registerKeyboardEvents() { + InputSystem.shared.registerKeyboardEvents() +} + +public func unregisterKeyboardEvents() { + InputSystem.shared.unregisterKeyboardEvents() +} + +public func registerMouseEvents() { + InputSystem.shared.registerMouseEvents() +} + +#if os(iOS) + import UIKit + + public func registerTouchEvents(view: UIView) { + InputSystem.shared.registerTouchEvents(view: view) + } + + public func unregisterTouchEvents() { + InputSystem.shared.unregisterTouchEvents() + } +#endif + // MARK: - Input Queries public func getXRSpatialInputState() -> XRSpatialInputState { @@ -61,6 +106,26 @@ public func isXRSceneReady() -> Bool { InputSystem.shared.isXRSceneReady() } +public func getPSVR2SenseState() -> PSVR2SenseControllerState { + InputSystem.shared.psvr2SenseControllerState +} + +public func isPSVR2SenseConnected() -> Bool { + InputSystem.shared.psvr2SenseControllerState.isConnected +} + +public func getKeyboardState() -> KeyState { + InputSystem.shared.keyState +} + +public func getGameControllerState() -> GameControllerState { + InputSystem.shared.gameControllerState +} + +public func getIOSTouchState() -> IOSTouchState { + InputSystem.shared.iosTouchState +} + // MARK: - Spatial Manipulation Config (visionOS) #if os(visionOS) diff --git a/Sources/UntoldEngineShaderSupport/include/UntoldEngineShaderSupport/UntoldModelSurface.h b/Sources/UntoldEngineShaderSupport/include/UntoldEngineShaderSupport/UntoldModelSurface.h new file mode 100644 index 00000000..93f75b64 --- /dev/null +++ b/Sources/UntoldEngineShaderSupport/include/UntoldEngineShaderSupport/UntoldModelSurface.h @@ -0,0 +1,80 @@ +// +// UntoldModelSurface.h +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef UntoldModelSurface_h +#define UntoldModelSurface_h + +#include + +#ifdef __METAL_VERSION__ + +#include + +typedef struct { + metal::float4 position [[attribute(UntoldModelVertexBufferPosition)]]; + metal::float4 normals [[attribute(UntoldModelVertexBufferNormal)]]; + metal::float2 uv [[attribute(UntoldModelVertexBufferUV)]]; + metal::float4 tangent [[attribute(UntoldModelVertexBufferTangent)]]; + metal::ushort4 jointIndices [[attribute(UntoldModelVertexBufferJointIDs)]]; + metal::float4 jointWeights [[attribute(UntoldModelVertexBufferJointWeights)]]; +} UntoldModelVertexIn; + +typedef struct { + metal::float4 position [[position]]; + metal::float4 shadowCoords; + metal::float4 vPosition; + metal::float4 verticesInMVSpace; + metal::float3 normalVectorInMVSpace; + metal::float3 normal; + metal::float3 tbNormal; + metal::float4 tangent; + metal::float2 uvCoords; +} UntoldModelSurfaceVertexOut; + +typedef struct { + metal::texture2d texture0 [[id(UntoldModelSurfaceExtensionArgumentTexture0)]]; + metal::texture2d texture1 [[id(UntoldModelSurfaceExtensionArgumentTexture1)]]; + metal::texture2d texture2 [[id(UntoldModelSurfaceExtensionArgumentTexture2)]]; + metal::texture2d texture3 [[id(UntoldModelSurfaceExtensionArgumentTexture3)]]; + metal::texture2d texture4 [[id(UntoldModelSurfaceExtensionArgumentTexture4)]]; + metal::texture2d texture5 [[id(UntoldModelSurfaceExtensionArgumentTexture5)]]; + metal::texture2d texture6 [[id(UntoldModelSurfaceExtensionArgumentTexture6)]]; + metal::texture2d texture7 [[id(UntoldModelSurfaceExtensionArgumentTexture7)]]; + + metal::sampler sampler0 [[id(UntoldModelSurfaceExtensionArgumentSampler0)]]; + metal::sampler sampler1 [[id(UntoldModelSurfaceExtensionArgumentSampler1)]]; + metal::sampler sampler2 [[id(UntoldModelSurfaceExtensionArgumentSampler2)]]; + metal::sampler sampler3 [[id(UntoldModelSurfaceExtensionArgumentSampler3)]]; + metal::sampler sampler4 [[id(UntoldModelSurfaceExtensionArgumentSampler4)]]; + metal::sampler sampler5 [[id(UntoldModelSurfaceExtensionArgumentSampler5)]]; + metal::sampler sampler6 [[id(UntoldModelSurfaceExtensionArgumentSampler6)]]; + metal::sampler sampler7 [[id(UntoldModelSurfaceExtensionArgumentSampler7)]]; + + constant metal::uchar *buffer0 [[id(UntoldModelSurfaceExtensionArgumentBuffer0)]]; + constant metal::uchar *buffer1 [[id(UntoldModelSurfaceExtensionArgumentBuffer1)]]; + constant metal::uchar *buffer2 [[id(UntoldModelSurfaceExtensionArgumentBuffer2)]]; + constant metal::uchar *buffer3 [[id(UntoldModelSurfaceExtensionArgumentBuffer3)]]; + constant metal::uchar *buffer4 [[id(UntoldModelSurfaceExtensionArgumentBuffer4)]]; + constant metal::uchar *buffer5 [[id(UntoldModelSurfaceExtensionArgumentBuffer5)]]; + constant metal::uchar *buffer6 [[id(UntoldModelSurfaceExtensionArgumentBuffer6)]]; + constant metal::uchar *buffer7 [[id(UntoldModelSurfaceExtensionArgumentBuffer7)]]; + constant metal::uchar *buffer8 [[id(UntoldModelSurfaceExtensionArgumentBuffer8)]]; + constant metal::uchar *buffer9 [[id(UntoldModelSurfaceExtensionArgumentBuffer9)]]; + constant metal::uchar *buffer10 [[id(UntoldModelSurfaceExtensionArgumentBuffer10)]]; + constant metal::uchar *buffer11 [[id(UntoldModelSurfaceExtensionArgumentBuffer11)]]; + constant metal::uchar *buffer12 [[id(UntoldModelSurfaceExtensionArgumentBuffer12)]]; + constant metal::uchar *buffer13 [[id(UntoldModelSurfaceExtensionArgumentBuffer13)]]; + constant metal::uchar *buffer14 [[id(UntoldModelSurfaceExtensionArgumentBuffer14)]]; + constant metal::uchar *buffer15 [[id(UntoldModelSurfaceExtensionArgumentBuffer15)]]; +} UntoldModelSurfaceExtensionArguments; + +#endif /* __METAL_VERSION__ */ + +#endif /* UntoldModelSurface_h */ diff --git a/Sources/UntoldEngineShaderSupport/include/UntoldEngineShaderSupport/UntoldShaderTypes.h b/Sources/UntoldEngineShaderSupport/include/UntoldEngineShaderSupport/UntoldShaderTypes.h new file mode 100644 index 00000000..451eefbd --- /dev/null +++ b/Sources/UntoldEngineShaderSupport/include/UntoldEngineShaderSupport/UntoldShaderTypes.h @@ -0,0 +1,102 @@ +// +// UntoldShaderTypes.h +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef UntoldShaderTypes_h +#define UntoldShaderTypes_h + +#include + +#ifdef __METAL_VERSION__ +typedef int UntoldEnumBackingType; +#else +#include +typedef int32_t UntoldEnumBackingType; +#endif + +#define UNTOLD_SHADER_BLOCK_SIZE 256 + +typedef struct { + matrix_float4x4 projectionMatrix; + matrix_float4x4 viewMatrix; + matrix_float4x4 modelViewMatrix; + matrix_float3x3 normalMatrix; + matrix_float4x4 modelMatrix; + simd_float3 cameraPosition; +} UntoldModelUniforms; + +typedef enum { + UntoldModelVertexBufferPosition, + UntoldModelVertexBufferNormal, + UntoldModelVertexBufferUV, + UntoldModelVertexBufferTangent, + UntoldModelVertexBufferJointIDs, + UntoldModelVertexBufferJointWeights, + UntoldModelVertexBufferBitangent, + UntoldModelVertexBufferUniforms, + UntoldModelVertexBufferJointTransforms, + UntoldModelVertexBufferHasArmature, +} UntoldModelVertexBufferIndex; + +typedef enum { + UntoldModelSurfaceExtensionFragmentBuffer0 = 10, + UntoldModelSurfaceExtensionFragmentBuffer1 = 11, + UntoldModelSurfaceExtensionFragmentBuffer2 = 12, + UntoldModelSurfaceExtensionFragmentBuffer3 = 13, +} UntoldModelSurfaceExtensionFragmentBufferIndex; + +typedef enum { + UntoldModelSurfaceExtensionFragmentTexture0 = 10, + UntoldModelSurfaceExtensionFragmentTexture1 = 11, + UntoldModelSurfaceExtensionFragmentTexture2 = 12, + UntoldModelSurfaceExtensionFragmentTexture3 = 13, +} UntoldModelSurfaceExtensionFragmentTextureIndex; + +typedef enum { + UntoldModelSurfaceExtensionArgumentBufferIndex = 10, +} UntoldModelSurfaceExtensionArgumentBufferSlot; + +typedef enum { + UntoldModelSurfaceExtensionArgumentTexture0 = 0, + UntoldModelSurfaceExtensionArgumentTexture1 = 1, + UntoldModelSurfaceExtensionArgumentTexture2 = 2, + UntoldModelSurfaceExtensionArgumentTexture3 = 3, + UntoldModelSurfaceExtensionArgumentTexture4 = 4, + UntoldModelSurfaceExtensionArgumentTexture5 = 5, + UntoldModelSurfaceExtensionArgumentTexture6 = 6, + UntoldModelSurfaceExtensionArgumentTexture7 = 7, + + UntoldModelSurfaceExtensionArgumentSampler0 = 8, + UntoldModelSurfaceExtensionArgumentSampler1 = 9, + UntoldModelSurfaceExtensionArgumentSampler2 = 10, + UntoldModelSurfaceExtensionArgumentSampler3 = 11, + UntoldModelSurfaceExtensionArgumentSampler4 = 12, + UntoldModelSurfaceExtensionArgumentSampler5 = 13, + UntoldModelSurfaceExtensionArgumentSampler6 = 14, + UntoldModelSurfaceExtensionArgumentSampler7 = 15, + + UntoldModelSurfaceExtensionArgumentBuffer0 = 16, + UntoldModelSurfaceExtensionArgumentBuffer1 = 17, + UntoldModelSurfaceExtensionArgumentBuffer2 = 18, + UntoldModelSurfaceExtensionArgumentBuffer3 = 19, + UntoldModelSurfaceExtensionArgumentBuffer4 = 20, + UntoldModelSurfaceExtensionArgumentBuffer5 = 21, + UntoldModelSurfaceExtensionArgumentBuffer6 = 22, + UntoldModelSurfaceExtensionArgumentBuffer7 = 23, + UntoldModelSurfaceExtensionArgumentBuffer8 = 24, + UntoldModelSurfaceExtensionArgumentBuffer9 = 25, + UntoldModelSurfaceExtensionArgumentBuffer10 = 26, + UntoldModelSurfaceExtensionArgumentBuffer11 = 27, + UntoldModelSurfaceExtensionArgumentBuffer12 = 28, + UntoldModelSurfaceExtensionArgumentBuffer13 = 29, + UntoldModelSurfaceExtensionArgumentBuffer14 = 30, + UntoldModelSurfaceExtensionArgumentBuffer15 = 31, +} UntoldModelSurfaceExtensionArgumentID; + +#endif /* UntoldShaderTypes_h */ diff --git a/Sources/UntoldEngineShaderSupport/shim.c b/Sources/UntoldEngineShaderSupport/shim.c new file mode 100644 index 00000000..e42e55e3 --- /dev/null +++ b/Sources/UntoldEngineShaderSupport/shim.c @@ -0,0 +1,11 @@ +// +// shim.c +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#include diff --git a/Sources/UntoldEngineXR/UntoldEngineXRShutdown.swift b/Sources/UntoldEngineXR/UntoldEngineXRShutdown.swift index 2948dcb8..c270080a 100644 --- a/Sources/UntoldEngineXR/UntoldEngineXRShutdown.swift +++ b/Sources/UntoldEngineXR/UntoldEngineXRShutdown.swift @@ -45,6 +45,7 @@ @MainActor public func shutdownUntoldEngineXR(_ xr: UntoldEngineXR, completion: (() -> Void)? = nil) { resetUntoldWorldForXR { + setRendering(.extensions(.removeAll)) xr.clearSpatialInput() xr.stop() completion?() diff --git a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift index 8ba1762b..5b8e8523 100644 --- a/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift +++ b/Tests/UntoldEngineRenderTests/BaseRenderSetup.swift @@ -73,6 +73,7 @@ class BaseRenderSetup: XCTestCase { entityNameMap.removeAll(keepingCapacity: true) reverseEntityNameMap.removeAll(keepingCapacity: true) clearCustomSystems(keepingCapacity: true) + RenderExtensionRegistry.shared.removeAll() globalEntityCounter = 0 timeSinceLastUpdatePreviousTime = nil timeSinceLastUpdate = nil diff --git a/Tests/UntoldEngineRenderTests/CompiledRenderGraphTest.swift b/Tests/UntoldEngineRenderTests/CompiledRenderGraphTest.swift new file mode 100644 index 00000000..8675113a --- /dev/null +++ b/Tests/UntoldEngineRenderTests/CompiledRenderGraphTest.swift @@ -0,0 +1,141 @@ +// +// CompiledRenderGraphTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +final class CompiledRenderGraphTest: XCTestCase { + func testCompileCapturesPassMetadataAndDeterministicOrder() throws { + let textureID: RenderTextureResourceID = "test.compiled.texture" + RenderResourceRegistry.shared.registerTexture( + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 1, height: 1), + pixelFormat: .rgba8Unorm, + usage: .shaderRead + ) + ) + defer { RenderResourceRegistry.shared.removeAll() } + let graph = [ + "surface": RenderPass( + id: "surface", + dependencies: ["prepare"], + execute: nil, + resourceUsages: [.texture(textureID, access: .read)], + stage: .beforePostProcess + ), + "prepare": RenderPass( + id: "prepare", + dependencies: [], + execute: nil + ), + ] + + let compiled = try compileRenderGraph(graph) + + XCTAssertEqual(compiled.executionOrder, ["prepare", "surface"]) + XCTAssertEqual(compiled.passesByID.count, 2) + XCTAssertEqual(compiled.passesByID["surface"]?.dependencies, ["prepare"]) + XCTAssertEqual( + compiled.passesByID["surface"]?.resourceUsages, + [.texture(textureID, access: .read)] + ) + XCTAssertEqual( + compiled.passesByID["surface"]?.owner, + .engine + ) + XCTAssertEqual(compiled.passesByID["surface"]?.stage, .beforePostProcess) + } + + func testCompilationOrderDoesNotDependOnDictionaryInsertionOrder() throws { + let passA = RenderPass(id: "a", dependencies: [], execute: nil) + let passB = RenderPass(id: "b", dependencies: [], execute: nil) + let passC = RenderPass(id: "c", dependencies: ["a", "b"], execute: nil) + var forward: [String: RenderPass] = [:] + var reverse: [String: RenderPass] = [:] + + forward[passA.id] = passA + forward[passB.id] = passB + forward[passC.id] = passC + reverse[passC.id] = passC + reverse[passB.id] = passB + reverse[passA.id] = passA + + let forwardOrder = try compileRenderGraph(forward).executionOrder + let reverseOrder = try compileRenderGraph(reverse).executionOrder + + XCTAssertEqual(forwardOrder, ["a", "b", "c"]) + XCTAssertEqual(reverseOrder, forwardOrder) + } + + func testBuilderCompilationPreservesResolvedStageOwnership() throws { + var builder = RenderGraphBuilder() + builder.addPass(id: "anchor", dependencies: [], execute: nil) + builder.beginExtensionRegistration(id: "test.compiled.extension") + builder.addPass( + id: "first", + stage: .afterTransparency, + execute: nil + ) + builder.addPass( + id: "second", + stage: .afterTransparency, + execute: nil + ) + XCTAssertTrue(builder.endExtensionRegistration().succeeded) + XCTAssertEqual( + builder.resolveStage(.afterTransparency, after: "anchor"), + "second" + ) + + let compiled = try builder.compile() + + XCTAssertEqual(compiled.executionOrder, ["anchor", "first", "second"]) + XCTAssertEqual(compiled.passesByID["first"]?.dependencies, ["anchor"]) + XCTAssertEqual(compiled.passesByID["second"]?.dependencies, ["first"]) + XCTAssertEqual( + compiled.passesByID["first"]?.owner, + .renderExtension("test.compiled.extension") + ) + XCTAssertEqual(compiled.passesByID["first"]?.stage, .afterTransparency) + XCTAssertEqual(compiled.passesByID["second"]?.stage, .afterTransparency) + } + + func testCompiledGraphIsSnapshotOfMutableSourceGraph() throws { + var graph = [ + "pass": RenderPass(id: "pass", dependencies: [], execute: nil), + ] + let compiled = try compileRenderGraph(graph) + + graph["pass"]?.dependencies.append("later-mutation") + graph["new"] = RenderPass(id: "new", dependencies: [], execute: nil) + + XCTAssertEqual(compiled.executionOrder, ["pass"]) + XCTAssertEqual(compiled.passesByID.count, 1) + XCTAssertEqual(compiled.passesByID["pass"]?.dependencies, []) + } + + func testCompilationRejectsMissingDependencyBeforeCreatingSnapshot() { + let graph = [ + "pass": RenderPass( + id: "pass", + dependencies: ["missing"], + execute: nil + ), + ] + + XCTAssertThrowsError(try compileRenderGraph(graph)) { error in + XCTAssertEqual( + error as? RenderGraphError, + .missingDependency(passID: "pass", dependencyID: "missing") + ) + } + } +} diff --git a/Tests/UntoldEngineRenderTests/MSAARenderingTests.swift b/Tests/UntoldEngineRenderTests/MSAARenderingTests.swift index 2c96ad92..d56392ee 100644 --- a/Tests/UntoldEngineRenderTests/MSAARenderingTests.swift +++ b/Tests/UntoldEngineRenderTests/MSAARenderingTests.swift @@ -208,7 +208,7 @@ final class MSAARenderingTests: BaseRenderSetup { updateOpaqueSampleCountForCurrentState() } - _ = buildGameModeGraph() + _ = try buildGameModeGraph() XCTAssertEqual(renderInfo.opaqueSampleCount, 4) XCTAssertEqual(textureResources.ssaoBlurTexture?.storageMode, .shared) diff --git a/Tests/UntoldEngineRenderTests/RenderExtensionPipelineDescriptorTest.swift b/Tests/UntoldEngineRenderTests/RenderExtensionPipelineDescriptorTest.swift new file mode 100644 index 00000000..4f816ad8 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderExtensionPipelineDescriptorTest.swift @@ -0,0 +1,356 @@ +// +// RenderExtensionPipelineDescriptorTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +@testable import UntoldEngine +import XCTest + +private final class TestRenderExtensionPipelineCreator: RenderExtensionPipelineCreating { + var failRenderPipelineIDs: Set = [] + var failComputePipelineIDs: Set = [] + + func makeRenderPipeline( + _ descriptor: RenderExtensionRenderPipelineDescriptor + ) -> RenderPipeline? { + guard !failRenderPipelineIDs.contains(descriptor.id) else { return nil } + return RenderPipeline(success: true, name: descriptor.name) + } + + func makeComputePipeline( + _ descriptor: RenderExtensionComputePipelineDescriptor, + library _: MTLLibrary + ) -> ComputePipeline? { + guard !failComputePipelineIDs.contains(descriptor.id) else { return nil } + var pipeline = ComputePipeline() + pipeline.name = descriptor.name + pipeline.success = true + return pipeline + } +} + +private final class TestDeclarativePipelineExtension: RenderExtension, @unchecked Sendable { + let id: String + let argumentLayoutID: String? + let renderDescriptors: [RenderExtensionRenderPipelineDescriptor] + let computeDescriptors: [RenderExtensionComputePipelineDescriptor] + + init( + id: String, + argumentLayoutID: String? = nil, + renderDescriptors: [RenderExtensionRenderPipelineDescriptor] = [], + computeDescriptors: [RenderExtensionComputePipelineDescriptor] = [] + ) { + self.id = id + self.argumentLayoutID = argumentLayoutID + self.renderDescriptors = renderDescriptors + self.computeDescriptors = computeDescriptors + } + + func registerArgumentBuffers(_ registry: RenderExtensionArgumentBufferRegistry) { + guard let argumentLayoutID else { return } + registry.registerArgumentBuffer( + RenderExtensionArgumentBufferDescriptor(id: argumentLayoutID) + ) + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + for descriptor in renderDescriptors { + registry.registerRenderPipeline(descriptor) + } + } + + func registerComputePipelines(_ registry: ComputePipelineRegistry) { + for descriptor in computeDescriptors { + registry.registerComputePipeline(descriptor) + } + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +private final class TestFailedCallbackPipelineExtension: RenderExtension, @unchecked Sendable { + let id = "com.untold.callback-failure" + let pipelineID: RenderPipelineType = "com.untold.callback-failure.render" + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerRenderPipeline(pipelineID) { nil } + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +final class RenderExtensionPipelineDescriptorTest: BaseRenderSetup { + func testValidDeclarativeRenderAndComputePipelinesRegister() { + let creator = TestRenderExtensionPipelineCreator() + let previousCreator = RenderExtensionPipelineCreator.shared.replaceForTesting(creator) + defer { _ = RenderExtensionPipelineCreator.shared.replaceForTesting(previousCreator) } + let argumentLayoutID = "com.untold.pipeline.arguments" + let renderDescriptor = makeRenderDescriptor( + id: "com.untold.pipeline.render", + requiredArgumentLayoutID: argumentLayoutID + ) + let computeDescriptor = makeComputeDescriptor(id: "com.untold.pipeline.compute") + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + argumentLayoutID: argumentLayoutID, + renderDescriptors: [renderDescriptor], + computeDescriptors: [computeDescriptor] + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[renderDescriptor.id]) + XCTAssertNotNil(ComputePipelineManager.shared.pipeline(for: computeDescriptor.id)) + } + + func testMissingRegisteredShaderLibraryRejectsRecipe() { + let missingLibraryID: RenderShaderLibraryID = "com.untold.pipeline.missing.library" + let descriptor = makeRenderDescriptor( + id: "com.untold.pipeline.missing-library", + vertexShaderLibrary: .registered(missingLibraryID) + ) + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [descriptor] + ) + let expectedError = RenderExtensionPipelineError.missingShaderLibrary( + pipelineID: descriptor.id.rawValue, + stage: .vertex, + libraryID: missingLibraryID.rawValue + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [expectedError] + ) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[descriptor.id]) + XCTAssertEqual( + RenderExtensionRegistry.shared.pipelineErrors(forExtensionID: renderExtension.id), + [expectedError] + ) + } + + func testMissingShaderFunctionRejectsRecipeBeforeCreation() { + let descriptor = makeRenderDescriptor( + id: "com.untold.pipeline.missing-function", + vertexFunction: "functionThatDoesNotExist" + ) + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [descriptor] + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [ + .missingShaderFunction( + pipelineID: descriptor.id.rawValue, + stage: .vertex, + function: "functionThatDoesNotExist" + ), + ] + ) + } + + func testMissingArgumentLayoutRejectsRecipe() { + let descriptor = makeRenderDescriptor( + id: "com.untold.pipeline.missing-layout", + requiredArgumentLayoutID: "com.untold.pipeline.missing.arguments" + ) + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [descriptor] + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [ + .missingArgumentLayout( + pipelineID: descriptor.id.rawValue, + layoutID: "com.untold.pipeline.missing.arguments" + ), + ] + ) + } + + func testInvalidRenderTargetConfigurationReportsEveryError() { + let descriptor = makeRenderDescriptor( + id: "com.untold.pipeline.invalid-target", + colorFormats: [.invalid], + depthFormat: .invalid, + depthEnabled: true + ) + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [descriptor] + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [ + .invalidColorFormat(pipelineID: descriptor.id.rawValue, index: 0), + .invalidDepthFormat(pipelineID: descriptor.id.rawValue), + ] + ) + } + + func testDuplicatePipelineRecipeRejectsAndRollsBackPipeline() { + let creator = TestRenderExtensionPipelineCreator() + let previousCreator = RenderExtensionPipelineCreator.shared.replaceForTesting(creator) + defer { _ = RenderExtensionPipelineCreator.shared.replaceForTesting(previousCreator) } + let descriptor = makeRenderDescriptor(id: "com.untold.pipeline.duplicate") + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [descriptor, descriptor] + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [ + .duplicatePipelineID( + kind: .renderPipeline, + pipelineID: descriptor.id.rawValue + ), + ] + ) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[descriptor.id]) + } + + func testPipelineCreationFailureIsStructuredAndTransactional() { + let descriptor = makeRenderDescriptor(id: "com.untold.pipeline.creation-failure") + let creator = TestRenderExtensionPipelineCreator() + creator.failRenderPipelineIDs = [descriptor.id] + let previousCreator = RenderExtensionPipelineCreator.shared.replaceForTesting(creator) + defer { _ = RenderExtensionPipelineCreator.shared.replaceForTesting(previousCreator) } + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [descriptor] + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [ + .creationFailed( + kind: .renderPipeline, + pipelineID: descriptor.id.rawValue + ), + ] + ) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + } + + func testFailedPipelineReplacementRestoresPreviousExtension() { + let extensionID = "com.untold.pipeline.replacement" + let originalDescriptor = makeRenderDescriptor( + id: "com.untold.pipeline.replacement.original" + ) + let replacementDescriptor = makeRenderDescriptor( + id: "com.untold.pipeline.replacement.new" + ) + let creator = TestRenderExtensionPipelineCreator() + creator.failRenderPipelineIDs = [replacementDescriptor.id] + let previousCreator = RenderExtensionPipelineCreator.shared.replaceForTesting(creator) + defer { _ = RenderExtensionPipelineCreator.shared.replaceForTesting(previousCreator) } + let original = TestDeclarativePipelineExtension( + id: extensionID, + renderDescriptors: [originalDescriptor] + ) + let replacement = TestDeclarativePipelineExtension( + id: extensionID, + renderDescriptors: [replacementDescriptor] + ) + XCTAssertEqual(RenderExtensionRegistry.shared.register(original), .registered) + + guard case .rejectedArtifacts = RenderExtensionRegistry.shared.register(replacement) else { + XCTFail("Expected pipeline replacement to be rejected") + return + } + + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [extensionID]) + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[originalDescriptor.id]) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[replacementDescriptor.id]) + } + + func testInvalidComputeRecipeRollsBackValidRenderRecipe() { + let creator = TestRenderExtensionPipelineCreator() + let previousCreator = RenderExtensionPipelineCreator.shared.replaceForTesting(creator) + defer { _ = RenderExtensionPipelineCreator.shared.replaceForTesting(previousCreator) } + let renderDescriptor = makeRenderDescriptor(id: "com.untold.pipeline.rollback.render") + let computeDescriptor = makeComputeDescriptor( + id: "com.untold.pipeline.rollback.compute", + function: "missingComputeFunction" + ) + let renderExtension = TestDeclarativePipelineExtension( + id: "com.untold.pipeline", + renderDescriptors: [renderDescriptor], + computeDescriptors: [computeDescriptor] + ) + + guard case .rejectedArtifacts = RenderExtensionRegistry.shared.register(renderExtension) else { + XCTFail("Expected invalid compute recipe to reject extension") + return + } + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[renderDescriptor.id]) + XCTAssertNil(ComputePipelineManager.shared.pipeline(for: computeDescriptor.id)) + } + + func testLegacyCallbackCreationFailureUsesStructuredDiagnostics() { + let renderExtension = TestFailedCallbackPipelineExtension() + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).pipelineErrors, + [ + .creationFailed( + kind: .renderPipeline, + pipelineID: renderExtension.pipelineID.rawValue + ), + ] + ) + } + + private func makeRenderDescriptor( + id: RenderPipelineType, + vertexFunction: String = "vertexCompositeShader", + vertexShaderLibrary: RenderShaderLibraryReference = .engine, + colorFormats: [MTLPixelFormat] = [.rgba16Float], + depthFormat: MTLPixelFormat = .depth32Float, + depthEnabled: Bool = false, + requiredArgumentLayoutID: String? = nil + ) -> RenderExtensionRenderPipelineDescriptor { + RenderExtensionRenderPipelineDescriptor( + id: id, + vertexFunction: vertexFunction, + fragmentFunction: "fragmentCompositeShader", + vertexShaderLibrary: vertexShaderLibrary, + vertexDescriptor: createCompositeVertexDescriptor(), + colorFormats: colorFormats, + depthFormat: depthFormat, + depthEnabled: depthEnabled, + name: id.rawValue, + requiredArgumentLayoutID: requiredArgumentLayoutID + ) + } + + private func makeComputeDescriptor( + id: ComputePipelineType, + function: String = "hzbBuildDepthPyramid" + ) -> RenderExtensionComputePipelineDescriptor { + RenderExtensionComputePipelineDescriptor( + id: id, + function: function, + name: id.rawValue + ) + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderExtensionPluginLifecycleTest.swift b/Tests/UntoldEngineRenderTests/RenderExtensionPluginLifecycleTest.swift new file mode 100644 index 00000000..bf4e818a --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderExtensionPluginLifecycleTest.swift @@ -0,0 +1,432 @@ +// +// RenderExtensionPluginLifecycleTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Dispatch +@testable import UntoldEngine +import XCTest + +private final class TestPluginLifecycleExtension: RenderExtension, @unchecked Sendable { + let id: String + let passID: String + let pipelineID: RenderPipelineType + let bufferID: RenderBufferResourceID + let missingTextureID: RenderTextureResourceID? + let pipelineCreationSucceeds: Bool + + init( + id: String, + pipelineID: RenderPipelineType? = nil, + missingTextureID: RenderTextureResourceID? = nil, + pipelineCreationSucceeds: Bool = true + ) { + self.id = id + passID = "\(id).pass" + self.pipelineID = pipelineID ?? RenderPipelineType("\(id).pipeline") + bufferID = RenderBufferResourceID("\(id).buffer") + self.missingTextureID = missingTextureID + self.pipelineCreationSucceeds = pipelineCreationSucceeds + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerRenderPipeline(pipelineID) { [pipelineCreationSucceeds] in + pipelineCreationSucceeds + ? RenderPipeline(success: true, name: self.pipelineID.rawValue) + : nil + } + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerBuffer( + RenderExtensionBufferDescriptor(id: bufferID, length: 64) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + let resources: [RenderGraphResourceUsage] + if let missingTextureID { + resources = [.texture(missingTextureID, access: .read)] + } else { + resources = [] + } + builder.addPass( + id: passID, + stage: .beforeOutput, + resources: resources, + execute: nil + ) + } +} + +private struct TestRenderingPlugin: RenderExtensionPlugin { + let manifest: RenderExtensionPluginManifest + let extensions: [any RenderExtension] + + init( + id: String, + version: RenderExtensionPluginVersion = .init(major: 1, minor: 0, patch: 0), + extensions: [any RenderExtension] + ) { + manifest = RenderExtensionPluginManifest( + id: id, + displayName: id, + version: version + ) + self.extensions = extensions + } + + func makeRenderExtensions() -> [any RenderExtension] { + extensions + } +} + +private final class TestBlockingPluginExtension: RenderExtension, @unchecked Sendable { + let id: String + let passID: String + let registrationStarted: DispatchSemaphore + let continueRegistration: DispatchSemaphore + + init( + id: String, + registrationStarted: DispatchSemaphore, + continueRegistration: DispatchSemaphore + ) { + self.id = id + passID = "\(id).pass" + self.registrationStarted = registrationStarted + self.continueRegistration = continueRegistration + } + + func registerResources(_: RenderResourceRegistry) { + registrationStarted.signal() + continueRegistration.wait() + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .beforeOutput, execute: nil) + } +} + +final class RenderExtensionPluginLifecycleTest: BaseRenderSetup { + func testMultiExtensionPluginInstallsAndPublishesManifest() { + let first = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let second = TestPluginLifecycleExtension(id: "com.untold.water.caustics") + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [first, second] + ) + + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + + XCTAssertEqual(RenderExtensionPluginRegistry.shared.installedPluginIDs(), [plugin.manifest.id]) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.installedManifests(), [plugin.manifest]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [first.id, second.id]) + XCTAssertNotNil(getRenderResource(first.bufferID)) + XCTAssertNotNil(getRenderResource(second.bufferID)) + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[first.pipelineID]) + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[second.pipelineID]) + } + + func testPluginInstallationRollsBackEveryExtensionWhenOneFails() { + let sharedPipelineID: RenderPipelineType = "com.untold.water.shared.pipeline" + let first = TestPluginLifecycleExtension( + id: "com.untold.water.first", + pipelineID: sharedPipelineID + ) + let second = TestPluginLifecycleExtension( + id: "com.untold.water.second", + pipelineID: sharedPipelineID + ) + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [first, second] + ) + + guard case let .rejected(failure) = RenderExtensionPluginRegistry.shared.install(plugin) else { + XCTFail("Expected plugin installation to fail") + return + } + + XCTAssertEqual(failure.extensionFailures.map(\.extensionID), [second.id]) + XCTAssertTrue(RenderExtensionPluginRegistry.shared.installedPluginIDs().isEmpty) + XCTAssertTrue(RenderExtensionRegistry.shared.registeredIDs().isEmpty) + XCTAssertNil(getRenderResource(first.bufferID)) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[sharedPipelineID]) + } + + func testPluginCannotReplaceStandaloneExtensionWithSameID() { + let standalone = TestPluginLifecycleExtension(id: "com.untold.water.surface") + XCTAssertEqual(RenderExtensionRegistry.shared.register(standalone), .registered) + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [TestPluginLifecycleExtension(id: standalone.id)] + ) + + XCTAssertEqual( + RenderExtensionPluginRegistry.shared.install(plugin), + .rejected( + RenderExtensionPluginFailure(conflictingExtensionIDs: [standalone.id]) + ) + ) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [standalone.id]) + XCTAssertNotNil(getRenderResource(standalone.bufferID)) + } + + func testSuccessfulPluginReplacementUpdatesExtensionsInPlace() { + let leading = TestPluginLifecycleExtension(id: "com.untold.leading") + XCTAssertEqual(RenderExtensionRegistry.shared.register(leading), .registered) + let original = TestPluginLifecycleExtension(id: "com.untold.water.original") + let originalPlugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [original] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(originalPlugin), .installed) + let trailing = TestPluginLifecycleExtension(id: "com.untold.trailing") + XCTAssertEqual(RenderExtensionRegistry.shared.register(trailing), .registered) + let replacement = TestPluginLifecycleExtension(id: "com.untold.water.replacement") + let replacementPlugin = TestRenderingPlugin( + id: "com.untold.water", + version: .init(major: 2, minor: 0, patch: 0), + extensions: [replacement] + ) + + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(replacementPlugin), .replaced) + + XCTAssertEqual( + RenderExtensionRegistry.shared.registeredIDs(), + [leading.id, replacement.id, trailing.id] + ) + XCTAssertNil(getRenderResource(original.bufferID)) + XCTAssertNotNil(getRenderResource(replacement.bufferID)) + XCTAssertEqual( + RenderExtensionPluginRegistry.shared.installedManifests(), + [replacementPlugin.manifest] + ) + } + + func testFailedPluginReplacementRestoresPreviousPlugin() { + let original = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let originalPlugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [original] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(originalPlugin), .installed) + let invalidReplacement = TestPluginLifecycleExtension( + id: original.id, + pipelineCreationSucceeds: false + ) + let replacementPlugin = TestRenderingPlugin( + id: "com.untold.water", + version: .init(major: 2, minor: 0, patch: 0), + extensions: [invalidReplacement] + ) + + guard case .rejected = RenderExtensionPluginRegistry.shared.install(replacementPlugin) else { + XCTFail("Expected plugin replacement to fail") + return + } + + XCTAssertEqual(RenderExtensionPluginRegistry.shared.installedManifests(), [originalPlugin.manifest]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [original.id]) + XCTAssertNotNil(getRenderResource(original.bufferID)) + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[original.pipelineID]) + } + + func testUninstallRemovesEveryPluginOwnedArtifact() { + let first = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let second = TestPluginLifecycleExtension(id: "com.untold.water.caustics") + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [first, second] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + + RenderExtensionPluginRegistry.shared.uninstall(id: plugin.manifest.id) + + XCTAssertTrue(RenderExtensionPluginRegistry.shared.installedPluginIDs().isEmpty) + XCTAssertTrue(RenderExtensionRegistry.shared.registeredIDs().isEmpty) + XCTAssertNil(getRenderResource(first.bufferID)) + XCTAssertNil(getRenderResource(second.bufferID)) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[first.pipelineID]) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[second.pipelineID]) + } + + func testLegacyExtensionUnregisterRemovesWholeOwningPlugin() { + let first = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let second = TestPluginLifecycleExtension(id: "com.untold.water.caustics") + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [first, second] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + + RenderExtensionRegistry.shared.unregister(id: first.id) + + XCTAssertTrue(RenderExtensionPluginRegistry.shared.installedPluginIDs().isEmpty) + XCTAssertTrue(RenderExtensionRegistry.shared.registeredIDs().isEmpty) + } + + func testStandaloneRegistrationCannotReplacePluginOwnedExtension() { + let pluginExtension = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [pluginExtension] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + let standaloneReplacement = TestPluginLifecycleExtension(id: pluginExtension.id) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(standaloneReplacement), + .rejectedPluginOwnership( + extensionID: pluginExtension.id, + pluginID: plugin.manifest.id + ) + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.installedPluginIDs(), [plugin.manifest.id]) + XCTAssertNotNil(getRenderResource(pluginExtension.bufferID)) + } + + func testGraphFailureRollsBackWholePluginAndPreservesHealthyPlugin() throws { + let healthyExtension = TestPluginLifecycleExtension(id: "com.untold.grass.surface") + let healthyPlugin = TestRenderingPlugin( + id: "com.untold.grass", + extensions: [healthyExtension] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(healthyPlugin), .installed) + let validWaterExtension = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let missingTextureID: RenderTextureResourceID = "com.untold.water.missing" + let invalidWaterExtension = TestPluginLifecycleExtension( + id: "com.untold.water.invalid", + missingTextureID: missingTextureID + ) + let invalidPlugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [validWaterExtension, invalidWaterExtension] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(invalidPlugin), .installed) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNotNil(graph[healthyExtension.passID]) + XCTAssertNil(graph[validWaterExtension.passID]) + XCTAssertNil(graph[invalidWaterExtension.passID]) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.installedPluginIDs(), [healthyPlugin.manifest.id]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [healthyExtension.id]) + XCTAssertNil(getRenderResource(validWaterExtension.bufferID)) + XCTAssertEqual( + RenderExtensionPluginRegistry.shared.failure(forPluginID: invalidPlugin.manifest.id)?.graphValidationErrors, + [ + .missingResource( + passID: invalidWaterExtension.passID, + kind: .texture, + resourceID: missingTextureID.rawValue + ), + ] + ) + } + + func testDeferredPipelineFailureRemovesWholePluginWhenMetalBecomesReady() { + let validExtension = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let invalidExtension = TestPluginLifecycleExtension( + id: "com.untold.water.invalid", + pipelineCreationSucceeds: false + ) + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [validExtension, invalidExtension] + ) + let device = renderInfo.device + let library = renderInfo.library + renderInfo.device = nil + renderInfo.library = nil + defer { + renderInfo.device = device + renderInfo.library = library + } + + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + renderInfo.device = device + renderInfo.library = library + + RenderExtensionRegistry.shared.registerPipelines() + + XCTAssertTrue(RenderExtensionPluginRegistry.shared.installedPluginIDs().isEmpty) + XCTAssertTrue(RenderExtensionRegistry.shared.registeredIDs().isEmpty) + XCTAssertNil(getRenderResource(validExtension.bufferID)) + XCTAssertFalse( + RenderExtensionPluginRegistry.shared.failure(forPluginID: plugin.manifest.id)? + .extensionFailures.isEmpty ?? true + ) + } + + func testPluginRemoveAllPreservesStandaloneExtensions() { + let standalone = TestPluginLifecycleExtension(id: "com.untold.standalone") + XCTAssertEqual(RenderExtensionRegistry.shared.register(standalone), .registered) + let pluginExtension = TestPluginLifecycleExtension(id: "com.untold.water.surface") + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [pluginExtension] + ) + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + + RenderExtensionPluginRegistry.shared.removeAll() + + XCTAssertTrue(RenderExtensionPluginRegistry.shared.installedPluginIDs().isEmpty) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [standalone.id]) + XCTAssertNotNil(getRenderResource(standalone.bufferID)) + XCTAssertNil(getRenderResource(pluginExtension.bufferID)) + } + + func testGraphBuildCannotObservePartiallyInstalledPlugin() throws { + let registrationStarted = DispatchSemaphore(value: 0) + let continueRegistration = DispatchSemaphore(value: 0) + let installCompleted = DispatchSemaphore(value: 0) + let graphStarted = DispatchSemaphore(value: 0) + let graphCompleted = DispatchSemaphore(value: 0) + let blockingExtension = TestBlockingPluginExtension( + id: "com.untold.water.blocking", + registrationStarted: registrationStarted, + continueRegistration: continueRegistration + ) + let trailingExtension = TestPluginLifecycleExtension(id: "com.untold.water.trailing") + let plugin = TestRenderingPlugin( + id: "com.untold.water", + extensions: [blockingExtension, trailingExtension] + ) + defer { continueRegistration.signal() } + + DispatchQueue.global().async { + _ = RenderExtensionPluginRegistry.shared.install(plugin) + installCompleted.signal() + } + XCTAssertEqual(registrationStarted.wait(timeout: .now() + 1), .success) + + DispatchQueue.global().async { + graphStarted.signal() + _ = try? buildGameModeGraph() + graphCompleted.signal() + } + XCTAssertEqual(graphStarted.wait(timeout: .now() + 1), .success) + XCTAssertEqual(graphCompleted.wait(timeout: .now() + 0.1), .timedOut) + + continueRegistration.signal() + XCTAssertEqual(installCompleted.wait(timeout: .now() + 2), .success) + XCTAssertEqual(graphCompleted.wait(timeout: .now() + 2), .success) + + let (graph, _) = try buildGameModeGraph() + XCTAssertNotNil(graph[blockingExtension.passID]) + XCTAssertNotNil(graph[trailingExtension.passID]) + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderExtensionPluginTest.swift b/Tests/UntoldEngineRenderTests/RenderExtensionPluginTest.swift new file mode 100644 index 00000000..d355f0df --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderExtensionPluginTest.swift @@ -0,0 +1,191 @@ +// +// RenderExtensionPluginTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +private final class TestPluginRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + + init(id: String) { + self.id = id + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +private struct TestRenderExtensionPlugin: RenderExtensionPlugin { + let manifest: RenderExtensionPluginManifest + let extensions: [any RenderExtension] + + func makeRenderExtensions() -> [any RenderExtension] { + extensions + } +} + +final class RenderExtensionPluginTest: XCTestCase { + func testPluginVersionsAreComparableAndCodable() throws { + let version = RenderExtensionPluginVersion(major: 1, minor: 4, patch: 2) + let newerVersion = RenderExtensionPluginVersion(major: 1, minor: 5, patch: 0) + + XCTAssertLessThan(version, newerVersion) + XCTAssertEqual(version.description, "1.4.2") + XCTAssertEqual( + try JSONDecoder().decode( + RenderExtensionPluginVersion.self, + from: JSONEncoder().encode(version) + ), + version + ) + } + + func testValidPluginContractPassesValidation() { + let plugin = makePlugin( + id: "com.untold.water", + extensionIDs: ["com.untold.water", "com.untold.water.caustics"] + ) + + let report = RenderExtensionPluginValidator.validate(plugin) + + XCTAssertTrue(report.isValid) + XCTAssertTrue(report.errors.isEmpty) + } + + func testManifestDefaultsToCurrentExtensionAPIVersion() { + let plugin = makePlugin(id: "com.untold.water") + + XCTAssertEqual(plugin.manifest.requiredAPIVersion, .current) + XCTAssertEqual(RenderExtensionAPIVersion.current.rawValue, 1) + } + + func testMalformedManifestReportsAllIndependentErrors() { + let plugin = makePlugin( + id: "water plugin", + displayName: " ", + requiredAPIVersion: RenderExtensionAPIVersion(2), + extensionIDs: [] + ) + + XCTAssertEqual( + RenderExtensionPluginValidator.validate(plugin).errors, + [ + .pluginIDMustBeNamespaced("water plugin"), + .emptyDisplayName(pluginID: "water plugin"), + .unsupportedAPIVersion( + pluginID: "water plugin", + required: RenderExtensionAPIVersion(2), + supported: .current + ), + .noExtensions(pluginID: "water plugin"), + ] + ) + } + + func testPluginIDRequiresPackageNamespace() { + let plugin = makePlugin(id: "water", extensionIDs: ["water"]) + + XCTAssertEqual( + RenderExtensionPluginValidator.validate(plugin).errors, + [.pluginIDMustBeNamespaced("water")] + ) + } + + func testWhitespaceOnlyPluginAndExtensionIDsAreEmpty() { + let plugin = makePlugin(id: " ", extensionIDs: ["\n"]) + + XCTAssertEqual( + RenderExtensionPluginValidator.validate(plugin).errors, + [ + .emptyPluginID, + .emptyExtensionID(pluginID: " "), + ] + ) + } + + func testExtensionIDsMustBelongToPluginNamespace() { + let plugin = makePlugin( + id: "com.untold.water", + extensionIDs: ["com.other.grass"] + ) + + XCTAssertEqual( + RenderExtensionPluginValidator.validate(plugin).errors, + [ + .extensionIDOutsidePluginNamespace( + pluginID: "com.untold.water", + extensionID: "com.other.grass" + ), + ] + ) + } + + func testDuplicateAndEmptyExtensionIDsAreRejected() { + let plugin = makePlugin( + id: "com.untold.water", + extensionIDs: ["com.untold.water.surface", "com.untold.water.surface", ""] + ) + + XCTAssertEqual( + RenderExtensionPluginValidator.validate(plugin).errors, + [ + .duplicateExtensionID( + pluginID: "com.untold.water", + extensionID: "com.untold.water.surface" + ), + .emptyExtensionID(pluginID: "com.untold.water"), + ] + ) + } + + func testPluginCollectionRejectsDuplicatePluginIDsOnce() { + let first = makePlugin(id: "com.untold.water") + let second = makePlugin(id: "com.untold.water") + let third = makePlugin(id: "com.untold.water") + + XCTAssertEqual( + RenderExtensionPluginValidator.validate([first, second, third]).errors, + [.duplicatePluginID("com.untold.water")] + ) + } + + func testValidationCanTargetExplicitEngineAPIVersion() { + let plugin = makePlugin( + id: "com.untold.water", + requiredAPIVersion: RenderExtensionAPIVersion(2) + ) + + XCTAssertTrue( + RenderExtensionPluginValidator.validate( + plugin, + supportedAPIVersion: RenderExtensionAPIVersion(2) + ).isValid + ) + } + + private func makePlugin( + id: String, + displayName: String = "Water Rendering", + requiredAPIVersion: RenderExtensionAPIVersion = .current, + extensionIDs: [String]? = nil + ) -> TestRenderExtensionPlugin { + TestRenderExtensionPlugin( + manifest: RenderExtensionPluginManifest( + id: id, + displayName: displayName, + version: RenderExtensionPluginVersion(major: 1, minor: 0, patch: 0), + requiredAPIVersion: requiredAPIVersion + ), + extensions: (extensionIDs ?? [id]).map(TestPluginRenderExtension.init(id:)) + ) + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderExtensionResourceDescriptorTest.swift b/Tests/UntoldEngineRenderTests/RenderExtensionResourceDescriptorTest.swift new file mode 100644 index 00000000..9c41d995 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderExtensionResourceDescriptorTest.swift @@ -0,0 +1,127 @@ +// +// RenderExtensionResourceDescriptorTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +@testable import UntoldEngine +import XCTest + +final class RenderExtensionResourceDescriptorTest: XCTestCase { + func testTypedResourceIDsPreserveRawValues() { + let textureID: RenderTextureResourceID = "water.reflection" + let bufferID: RenderBufferResourceID = "water.uniforms" + + XCTAssertEqual(textureID.rawValue, "water.reflection") + XCTAssertEqual(bufferID.rawValue, "water.uniforms") + } + + func testValidTextureDescriptorHasNoValidationErrors() throws { + let descriptor = RenderExtensionTextureDescriptor( + id: RenderTextureResourceID("water.reflection"), + size: .viewportScale(0.5), + pixelFormat: .rgba16Float, + usage: [.renderTarget, .shaderRead] + ) + + XCTAssertTrue(descriptor.validationErrors().isEmpty) + XCTAssertNoThrow(try descriptor.validate()) + XCTAssertEqual(descriptor.label, "water.reflection") + } + + func testTextureDescriptorReportsAllIndependentValidationErrors() { + let descriptor = RenderExtensionTextureDescriptor( + id: " ", + size: .viewportScale(-0.5), + pixelFormat: .rgba16Float, + usage: [], + mipMapLevels: 0, + sampleCount: 0 + ) + + XCTAssertEqual(descriptor.validationErrors(), [ + .emptyID, + .invalidViewportScale(id: " ", scale: -0.5), + .emptyTextureUsage(id: " "), + .invalidMipMapLevels(id: " ", count: 0), + .invalidSampleCount(id: " ", count: 0), + ]) + } + + func testTextureDescriptorRejectsInvalidFixedDimensions() { + let descriptor = RenderExtensionTextureDescriptor( + id: "water.invalid-size", + size: .fixed(width: 0, height: -1), + pixelFormat: .rgba16Float, + usage: .shaderRead + ) + + XCTAssertEqual( + descriptor.validationErrors(), + [.invalidTextureDimensions(id: "water.invalid-size", width: 0, height: -1)] + ) + } + + func testTextureDescriptorRejectsMipmappedMultisampling() { + let descriptor = RenderExtensionTextureDescriptor( + id: "water.invalid-msaa", + size: .fixed(width: 64, height: 64), + pixelFormat: .rgba16Float, + usage: .renderTarget, + mipMapLevels: 2, + sampleCount: 4 + ) + + XCTAssertEqual( + descriptor.validationErrors(), + [.multisampledTextureHasMipmaps(id: "water.invalid-msaa")] + ) + } + + func testValidBufferDescriptorHasNoValidationErrors() throws { + let descriptor = RenderExtensionBufferDescriptor( + id: RenderBufferResourceID("water.uniforms"), + length: 256 + ) + + XCTAssertTrue(descriptor.validationErrors().isEmpty) + XCTAssertNoThrow(try descriptor.validate()) + XCTAssertEqual(descriptor.label, "water.uniforms") + XCTAssertEqual(descriptor.lifetime, .persistent) + } + + func testResourceDescriptorsAcceptTransientLifetime() { + let texture = RenderExtensionTextureDescriptor( + id: "water.transient-texture", + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba8Unorm, + usage: .renderTarget, + lifetime: .transient + ) + let buffer = RenderExtensionBufferDescriptor( + id: "water.transient-buffer", + length: 64, + lifetime: .transient + ) + + XCTAssertEqual(texture.lifetime, .transient) + XCTAssertEqual(buffer.lifetime, .transient) + } + + func testBufferDescriptorRejectsEmptyIDAndInvalidLength() { + let descriptor = RenderExtensionBufferDescriptor(id: "", length: 0) + + XCTAssertEqual(descriptor.validationErrors(), [ + .emptyID, + .invalidBufferLength(id: "", length: 0), + ]) + XCTAssertThrowsError(try descriptor.validate()) { error in + XCTAssertEqual(error as? RenderExtensionResourceValidationError, .emptyID) + } + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift b/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift index 6dae791e..6e8103fc 100644 --- a/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift +++ b/Tests/UntoldEngineRenderTests/RenderGraphBuilderTest.swift @@ -9,9 +9,660 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import Foundation +import Metal +import simd @testable import UntoldEngine import XCTest +private final class TestStageRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let passID: String + let stage: RenderStage + + init(id: String? = nil, passID: String, stage: RenderStage) { + self.id = id ?? "test.stage.extension.\(passID)" + self.passID = passID + self.stage = stage + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: stage, execute: { _ in }) + } +} + +private final class TestMultiPassConflictRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let uniquePassID: String + let conflictingPassID: String + + init(id: String, uniquePassID: String, conflictingPassID: String) { + self.id = id + self.uniquePassID = uniquePassID + self.conflictingPassID = conflictingPassID + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: uniquePassID, stage: .afterTransparency, execute: nil) + builder.addPass(id: conflictingPassID, stage: .afterTransparency, execute: nil) + } +} + +private final class TestInvalidResourceThenConflictRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let uniquePassID: String + let conflictingPassID: String + let missingTextureID: RenderTextureResourceID + + init( + id: String, + uniquePassID: String, + conflictingPassID: String, + missingTextureID: RenderTextureResourceID + ) { + self.id = id + self.uniquePassID = uniquePassID + self.conflictingPassID = conflictingPassID + self.missingTextureID = missingTextureID + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: uniquePassID, + stage: .afterTransparency, + resources: [.texture(missingTextureID, access: .read)], + execute: nil + ) + builder.addPass(id: conflictingPassID, stage: .afterTransparency, execute: nil) + } +} + +private final class TestInvalidDependencyRenderExtension: RenderExtension, @unchecked Sendable { + let id = "test.invalid.dependency.extension" + let passID = "test.invalid.dependency.pass" + let dependencyID = "test.missing.dependency" + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: passID, + stage: .beforeOutput, + dependencies: [dependencyID], + execute: nil + ) + } +} + +private final class TestLifecycleRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let passID: String + let textureID: String + let bufferID: RenderBufferResourceID + let shaderLibraryID: RenderShaderLibraryID + let renderPipelineType: RenderPipelineType + let computePipelineType: ComputePipelineType + let argumentLayoutID: String + let shaderLibrary: MTLLibrary + + init( + id: String = "test.lifecycle.extension", + artifactSuffix: String, + passID: String? = nil, + renderPipelineType: RenderPipelineType? = nil, + shaderLibrary: MTLLibrary + ) { + self.id = id + self.passID = passID ?? "test.lifecycle.\(artifactSuffix).pass" + textureID = "test.lifecycle.\(artifactSuffix).texture" + bufferID = RenderBufferResourceID("test.lifecycle.\(artifactSuffix).buffer") + shaderLibraryID = RenderShaderLibraryID("test.lifecycle.\(artifactSuffix).library") + self.renderPipelineType = renderPipelineType ?? RenderPipelineType("test.lifecycle.\(artifactSuffix).render") + computePipelineType = ComputePipelineType("test.lifecycle.\(artifactSuffix).compute") + argumentLayoutID = "test.lifecycle.\(artifactSuffix).arguments" + self.shaderLibrary = shaderLibrary + } + + func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerLibrary(shaderLibraryID, library: shaderLibrary) + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerRenderPipeline(renderPipelineType) { + RenderPipeline(success: true, name: self.renderPipelineType.rawValue) + } + } + + func registerComputePipelines(_ registry: ComputePipelineRegistry) { + registry.registerComputePipeline(computePipelineType) { + var pipeline = ComputePipeline() + pipeline.name = self.computePipelineType.rawValue + pipeline.success = true + return pipeline + } + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: [.renderTarget, .shaderRead] + ) + ) + registry.registerBuffer( + RenderExtensionBufferDescriptor( + id: bufferID, + length: 64 + ) + ) + } + + func registerArgumentBuffers(_ registry: RenderExtensionArgumentBufferRegistry) { + registry.registerArgumentBuffer( + RenderExtensionArgumentBufferDescriptor( + id: argumentLayoutID, + buffers: [ + RenderExtensionArgumentBuffer( + id: RenderExtensionModelSurfaceArgument.buffer0 + ), + ] + ) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .afterTransparency, execute: nil) + } +} + +private final class TestRenderPipelineOnlyExtension: RenderExtension, @unchecked Sendable { + let id: String + let pipelineType: RenderPipelineType + let passID: String + + init(id: String, pipelineType: RenderPipelineType, passID: String) { + self.id = id + self.pipelineType = pipelineType + self.passID = passID + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerRenderPipeline(pipelineType) { + RenderPipeline(success: true, name: self.id) + } + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .afterTransparency, execute: nil) + } +} + +private final class TestResourceRenderExtension: RenderExtension, @unchecked Sendable { + let id = "test.resource.extension" + let textureID: String + let passID: String + let size: RenderExtensionResourceSize + var observedTextureSize: SIMD2? + + init( + textureID: String, + passID: String = "test.resource.pass", + size: RenderExtensionResourceSize + ) { + self.textureID = textureID + self.passID = passID + self.size = size + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: textureID, + size: size, + pixelFormat: .rgba16Float, + usage: [.renderTarget, .shaderRead] + ) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: passID, + stage: .beforeOutput, + resources: [ + .texture(RenderTextureResourceID(textureID), access: .read), + ], + execute: { [weak self] context in + guard let self, let texture = context.resources.texture(textureID) else { return } + observedTextureSize = SIMD2(texture.width, texture.height) + } + ) + } +} + +private final class TestBufferResourceRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let bufferID: RenderBufferResourceID + let passID: String + let length: Int + var observedBufferLength: Int? + + init( + id: String = "test.buffer-resource.extension", + bufferID: RenderBufferResourceID, + passID: String = "test.buffer-resource.pass", + length: Int + ) { + self.id = id + self.bufferID = bufferID + self.passID = passID + self.length = length + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerBuffer( + RenderExtensionBufferDescriptor( + id: bufferID, + length: length + ) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: passID, + stage: .beforeOutput, + resources: [ + .buffer(bufferID, access: .read), + ] + ) { [weak self] context in + guard let self else { return } + observedBufferLength = context.resources.buffer(bufferID)?.length + } + } +} + +private final class TestTransactionalResourceRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let textureDescriptors: [RenderExtensionTextureDescriptor] + let bufferDescriptors: [RenderExtensionBufferDescriptor] + private(set) var resourceRegistrationCount = 0 + + init( + id: String, + textureDescriptors: [RenderExtensionTextureDescriptor] = [], + bufferDescriptors: [RenderExtensionBufferDescriptor] = [] + ) { + self.id = id + self.textureDescriptors = textureDescriptors + self.bufferDescriptors = bufferDescriptors + } + + func registerResources(_ registry: RenderResourceRegistry) { + resourceRegistrationCount += 1 + for descriptor in textureDescriptors { + registry.registerTexture(descriptor) + } + for descriptor in bufferDescriptors { + registry.registerBuffer(descriptor) + } + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +private final class TestResourceUsageRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let passID: String? + let textureDescriptors: [RenderExtensionTextureDescriptor] + let bufferDescriptors: [RenderExtensionBufferDescriptor] + let usages: [RenderGraphResourceUsage] + let observedTextureIDs: [RenderTextureResourceID] + let observedBufferIDs: [RenderBufferResourceID] + private(set) var observedTextures: [RenderTextureResourceID: Bool] = [:] + private(set) var observedBuffers: [RenderBufferResourceID: Bool] = [:] + + init( + id: String, + passID: String? = nil, + textureDescriptors: [RenderExtensionTextureDescriptor] = [], + bufferDescriptors: [RenderExtensionBufferDescriptor] = [], + usages: [RenderGraphResourceUsage] = [], + observedTextureIDs: [RenderTextureResourceID] = [], + observedBufferIDs: [RenderBufferResourceID] = [] + ) { + self.id = id + self.passID = passID + self.textureDescriptors = textureDescriptors + self.bufferDescriptors = bufferDescriptors + self.usages = usages + self.observedTextureIDs = observedTextureIDs + self.observedBufferIDs = observedBufferIDs + } + + func registerResources(_ registry: RenderResourceRegistry) { + for descriptor in textureDescriptors { + registry.registerTexture(descriptor) + } + for descriptor in bufferDescriptors { + registry.registerBuffer(descriptor) + } + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + guard let passID else { return } + builder.addPass(id: passID, stage: .beforeOutput, resources: usages) { [weak self] context in + guard let self else { return } + for id in observedTextureIDs { + observedTextures[id] = context.resources.texture(id) != nil + } + for id in observedBufferIDs { + observedBuffers[id] = context.resources.buffer(id) != nil + } + } + } +} + +private final class TestRenderExtensionResourceAllocator: RenderExtensionResourceAllocating { + var failingTextureIDs: Set = [] + var failingBufferIDs: Set = [] + private(set) var textureAllocationCounts: [RenderTextureResourceID: Int] = [:] + private(set) var bufferAllocationCounts: [RenderBufferResourceID: Int] = [:] + + func makeTexture( + device: MTLDevice, + descriptor: RenderExtensionTextureDescriptor, + width: Int, + height: Int + ) -> MTLTexture? { + textureAllocationCounts[descriptor.id, default: 0] += 1 + guard !failingTextureIDs.contains(descriptor.id) else { return nil } + return createTexture( + device: device, + label: descriptor.label, + pixelFormat: descriptor.pixelFormat, + width: width, + height: height, + usage: descriptor.usage, + storageMode: descriptor.storageMode, + mipMapLevels: descriptor.mipMapLevels, + sampleCount: descriptor.sampleCount + ) + } + + func makeBuffer( + device: MTLDevice, + descriptor: RenderExtensionBufferDescriptor + ) -> MTLBuffer? { + bufferAllocationCounts[descriptor.id, default: 0] += 1 + guard !failingBufferIDs.contains(descriptor.id) else { return nil } + return createEmptyBuffer( + device: device, + length: descriptor.length, + options: descriptor.options, + label: descriptor.label + ) + } +} + +private final class TestComputeRenderExtension: RenderExtension, @unchecked Sendable { + let id = "test.compute.extension" + let computeType: ComputePipelineType + let passID: String + var observedPipelineName: String? + + init( + computeType: ComputePipelineType, + passID: String = "test.compute.pass" + ) { + self.computeType = computeType + self.passID = passID + } + + func registerComputePipelines(_ registry: ComputePipelineRegistry) { + registry.registerComputePipeline(computeType) { + var pipeline = ComputePipeline() + pipeline.name = "Test Compute Pipeline" + pipeline.success = true + return pipeline + } + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .beforeOutput, execute: { [weak self] context in + guard let self else { return } + observedPipelineName = context.computePipelines.pipeline(computeType)?.name + }) + } +} + +private final class TestShaderLibraryRenderExtension: RenderExtension, @unchecked Sendable { + let id = "test.shader.library.extension" + let libraryID: RenderShaderLibraryID + let library: MTLLibrary + + init(libraryID: RenderShaderLibraryID, library: MTLLibrary) { + self.libraryID = libraryID + self.library = library + } + + func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerLibrary(libraryID, library: library) + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +private final class TestRegisteredShaderPipelineRenderExtension: RenderExtension, @unchecked Sendable { + let id = "test.registered.shader.pipeline.extension" + let libraryID: RenderShaderLibraryID + let renderPipelineType: RenderPipelineType + let computePipelineType: ComputePipelineType + let library: MTLLibrary + + init( + libraryID: RenderShaderLibraryID, + renderPipelineType: RenderPipelineType, + computePipelineType: ComputePipelineType, + library: MTLLibrary + ) { + self.libraryID = libraryID + self.renderPipelineType = renderPipelineType + self.computePipelineType = computePipelineType + self.library = library + } + + func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerLibrary(libraryID, library: library) + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerRenderPipeline( + renderPipelineType, + vertexShader: "vertexCompositeShader", + fragmentShader: "fragmentCompositeShader", + vertexShaderLibrary: .registered(libraryID), + fragmentShaderLibrary: .registered(libraryID), + vertexDescriptor: createCompositeVertexDescriptor(), + colorFormats: [renderInfo.presentColorPixelFormat], + depthFormat: renderInfo.presentDepthPixelFormat, + depthEnabled: false, + name: "Test Registered Shader Render Pipeline" + ) + } + + func registerComputePipelines(_ registry: ComputePipelineRegistry) { + registry.registerComputePipeline( + computePipelineType, + functionName: "hzbBuildDepthPyramid", + shaderLibrary: .registered(libraryID), + pipelineName: "Test Registered Shader Compute Pipeline" + ) + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +private final class TestModelSurfaceDrawRenderExtension: RenderExtension, @unchecked Sendable { + let id = "test.model.surface.draw.extension" + let pipelineType: RenderPipelineType + let passID: String + var boundEntityIDs: [EntityID] = [] + + init( + pipelineType: RenderPipelineType, + passID: String = "test.model.surface.draw.pass" + ) { + self.pipelineType = pipelineType + self.passID = passID + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerModelSurfacePipeline( + pipelineType, + fragmentShader: "fragmentGeometryShader", + depthEnabled: true, + name: "Test Model Surface Pipeline" + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .beforePostProcess) { [weak self] context in + guard let self else { return } + context.drawModelSurfaceEntities( + pipeline: pipelineType, + label: "Test Model Surface Draw" + ) { _, entityId, _ in + self.boundEntityIDs.append(entityId) + } + } + } +} + +private final class TestModelSurfaceArgumentDrawRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let pipelineType: RenderPipelineType + let passID: String + let argumentLayoutID: String? + let fragmentShader: String + let fragmentLibraryID: RenderShaderLibraryID? + let fragmentLibrary: MTLLibrary? + var boundEntityIDs: [EntityID] = [] + var encodedValues: [Float] = [] + + init( + id: String = "test.model.surface.argument.draw.extension", + pipelineType: RenderPipelineType, + passID: String = "test.model.surface.argument.draw.pass", + argumentLayoutID: String? = nil, + fragmentShader: String = "fragmentGeometryShader", + fragmentLibraryID: RenderShaderLibraryID? = nil, + fragmentLibrary: MTLLibrary? = nil + ) { + self.id = id + self.pipelineType = pipelineType + self.passID = passID + self.argumentLayoutID = argumentLayoutID + self.fragmentShader = fragmentShader + self.fragmentLibraryID = fragmentLibraryID + self.fragmentLibrary = fragmentLibrary + } + + func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + guard let fragmentLibraryID, let fragmentLibrary else { return } + registry.registerLibrary(fragmentLibraryID, library: fragmentLibrary) + } + + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerModelSurfacePipeline( + pipelineType, + fragmentShader: fragmentShader, + fragmentShaderLibrary: fragmentLibraryID.map(RenderShaderLibraryReference.registered) ?? .engine, + depthEnabled: true, + name: "Test Model Surface Argument Pipeline" + ) + } + + func registerArgumentBuffers(_ registry: RenderExtensionArgumentBufferRegistry) { + guard let argumentLayoutID else { return } + + registry.registerArgumentBuffer( + RenderExtensionArgumentBufferDescriptor( + id: argumentLayoutID, + buffers: [ + RenderExtensionArgumentBuffer( + id: RenderExtensionModelSurfaceArgument.buffer0 + ), + ] + ) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass(id: passID, stage: .beforePostProcess) { [weak self] context in + guard let self else { return } + context.drawModelSurfaceEntities( + pipeline: pipelineType, + label: "Test Model Surface Argument Draw", + argumentLayoutID: argumentLayoutID, + bindArguments: { arguments, entityId, _ in + var value = Float(self.boundEntityIDs.count + 1) + arguments.setBytes( + &value, + id: RenderExtensionModelSurfaceArgument.buffer0 + ) + self.boundEntityIDs.append(entityId) + self.encodedValues.append(value) + } + ) + } + } +} + final class RenderGraphBuilderTest: BaseRenderSetup { override func setUp() async throws { try await super.setUp() @@ -148,7 +799,8 @@ final class RenderGraphBuilderTest: BaseRenderSetup { } func testPostProcessingEffects_TopologicalOrder() throws { - var graph = [String: RenderPass]() + let lightPass = RenderPass(id: "lightPass", dependencies: [], execute: nil) + var graph = [lightPass.id: lightPass] BloomThresholdParams.shared.enabled = true @@ -176,12 +828,12 @@ final class RenderGraphBuilderTest: BaseRenderSetup { // MARK: - buildGameModeGraph Integration Tests - func testBuildGameModeGraph_CreatesCompleteGraph() { + func testBuildGameModeGraph_CreatesCompleteGraph() throws { // Set up environment for non-XR rendering renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify essential passes exist XCTAssertNotNil(graph["environment"], "Environment pass should exist") @@ -200,11 +852,11 @@ final class RenderGraphBuilderTest: BaseRenderSetup { XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") } - func testBuildGameModeGraph_GridMode() { + func testBuildGameModeGraph_GridMode() throws { renderInfo.immersionStyle = .none renderEnvironment = false // Should use grid instead - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["grid"], "Grid pass should exist when renderEnvironment is false") XCTAssertNil(graph["environment"], "Environment pass should not exist") @@ -214,10 +866,10 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Shadow should depend on grid in grid mode") } - func testBuildGameModeGraph_XRPassthroughMode() { + func testBuildGameModeGraph_XRPassthroughMode() throws { renderInfo.immersionStyle = .mixed - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNil(graph["environment"], "Environment pass should not exist in passthrough mode") XCTAssertNil(graph["grid"], "Grid pass should not exist in passthrough mode") @@ -227,10 +879,10 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Shadow should have no dependencies in passthrough mode") } - func testBuildGameModeGraph_XRFullImmersionMode() { + func testBuildGameModeGraph_XRFullImmersionMode() throws { renderInfo.immersionStyle = .full - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["environment"], "Environment pass should exist in full immersion mode") XCTAssertNil(graph["grid"], "Grid pass should not exist in full immersion mode") @@ -246,7 +898,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { DepthOfFieldParams.shared.enabled = true defer { DepthOfFieldParams.shared.enabled = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() let sorted = try topologicalSortGraph(graph: graph) let order = sorted.map(\.id) @@ -270,13 +922,13 @@ final class RenderGraphBuilderTest: BaseRenderSetup { ]) } - func testBuildGameModeGraph_BypassPostProcessing_UsesBypassPass() { + func testBuildGameModeGraph_BypassPostProcessing_UsesBypassPass() throws { renderInfo.immersionStyle = .none renderEnvironment = true bypassPostProcessing = true defer { bypassPostProcessing = false } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") XCTAssertNotNil(graph["wireframe"], "Wireframe pass should exist") @@ -310,7 +962,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Output transform should depend on fxaa when bypassing post-processing") } - func testBuildGameModeGraph_FXAAEdgeDebugUsesDiagnosticPass() { + func testBuildGameModeGraph_FXAAEdgeDebugUsesDiagnosticPass() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .none @@ -320,7 +972,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderDebugViewMode = .lit } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") XCTAssertNotNil(graph["look"], "Look pass should exist") @@ -332,13 +984,13 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Output transform should read the FXAA edge debug output") } - func testBuildGameModeGraph_SMAAUsesThreePassChain() { + func testBuildGameModeGraph_SMAAUsesThreePassChain() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .smaa defer { antiAliasingMode = .fxaa } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") XCTAssertNotNil(graph["look"], "Look pass should exist") @@ -356,7 +1008,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Output transform should read the SMAA neighborhood output") } - func testBuildGameModeGraph_SMAAEdgesDebugStopsAfterEdgePass() { + func testBuildGameModeGraph_SMAAEdgesDebugStopsAfterEdgePass() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .none @@ -366,7 +1018,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderDebugViewMode = .lit } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") XCTAssertNotNil(graph["smaaEdges"], "SMAA edge pass should exist for edge debug") @@ -378,7 +1030,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Output transform should read the SMAA edge texture") } - func testBuildGameModeGraph_SMAABlendDebugStopsAfterBlendPass() { + func testBuildGameModeGraph_SMAABlendDebugStopsAfterBlendPass() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .none @@ -388,7 +1040,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderDebugViewMode = .lit } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") XCTAssertNotNil(graph["smaaEdges"], "SMAA edge pass should exist for blend debug") @@ -402,7 +1054,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Output transform should read the SMAA blend texture") } - func testBuildGameModeGraph_SMAADifferenceDebugRunsFullChain() { + func testBuildGameModeGraph_SMAADifferenceDebugRunsFullChain() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .none @@ -412,7 +1064,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderDebugViewMode = .lit } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform", "Final pass should be outputTransform") XCTAssertNotNil(graph["smaaEdges"], "SMAA edge pass should exist for difference debug") @@ -435,7 +1087,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { /// When antiAliasingMode is .none, no AA pass of any kind should appear in the graph /// and outputTransform must depend directly on look rather than on an AA pass. - func testBuildGameModeGraph_NoAAMode_HasNoAAPassAndConnectsDirectlyToOutput() { + func testBuildGameModeGraph_NoAAMode_HasNoAAPassAndConnectsDirectlyToOutput() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .none @@ -445,7 +1097,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderDebugViewMode = .lit } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(finalPassID, "outputTransform") XCTAssertNil(graph["fxaa"], "No FXAA pass when antiAliasingMode is .none") @@ -456,7 +1108,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "outputTransform must depend directly on look when no AA is active") } - func testBuildGameModeGraph_MSAAMode_UsesOpaqueResolveAndNoPostAAPass() { + func testBuildGameModeGraph_MSAAMode_UsesOpaqueResolveAndNoPostAAPass() throws { guard renderInfo.device.supportsTextureSampleCount(4) else { return } @@ -471,7 +1123,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { updateOpaqueSampleCountForCurrentState() } - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(renderInfo.opaqueSampleCount, 4, "buildGameModeGraph must reconcile MSAA mode changes from DemoHUD") @@ -492,19 +1144,19 @@ final class RenderGraphBuilderTest: BaseRenderSetup { /// Switching renderDebugViewMode to any G-Buffer visualization mode must not change the /// set of passes in the render graph. The routing change is inside the look pass execution /// closure, not in the graph topology. - func testBuildGameModeGraph_GBufferDebugModes_ProduceSamePassSetAsLitMode() { + func testBuildGameModeGraph_GBufferDebugModes_ProduceSamePassSetAsLitMode() throws { renderInfo.immersionStyle = .none renderEnvironment = true antiAliasingMode = .fxaa defer { renderDebugViewMode = .lit } renderDebugViewMode = .lit - let (baseGraph, _) = buildGameModeGraph() + let (baseGraph, _) = try buildGameModeGraph() let baseKeys = Set(baseGraph.keys) for mode in [RenderDebugViewMode.albedo, .normal, .depth, .ssaoBlurred] { renderDebugViewMode = mode - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() XCTAssertEqual(Set(graph.keys), baseKeys, "Graph pass set must equal .lit mode for .\(mode) debug mode") XCTAssertEqual(finalPassID, "outputTransform", @@ -521,7 +1173,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { /// Note: when ALL effects are disabled, postProcessingEffects() short-circuits to a /// lightweight bypass pass (postProcessDisabledBypass) — bloomComposite never appears. /// This test enables vignette to keep the full chain alive while isolating bloom state. - func testBuildGameModeGraph_BloomDisabled_BlurPassesAbsent() { + func testBuildGameModeGraph_BloomDisabled_BlurPassesAbsent() throws { renderInfo.immersionStyle = .none renderEnvironment = true @@ -534,7 +1186,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { VignetteParams.shared.enabled = wasVignette } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // No blur passes should exist when bloom is disabled. XCTAssertNil(graph["blur_pass_hor_pass1"], "Horizontal blur pass 1 must not exist when bloom is disabled") @@ -549,47 +1201,47 @@ final class RenderGraphBuilderTest: BaseRenderSetup { // MARK: - Gaussian Pass Integration Tests - func testBuildGameModeGraph_GaussianPassExists() { + func testBuildGameModeGraph_GaussianPassExists() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["gaussian"], "Gaussian pass should exist in game mode graph") } - func testBuildGameModeGraph_GaussianDependsOnModel() { + func testBuildGameModeGraph_GaussianDependsOnModel() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertEqual(graph["gaussian"]?.dependencies, ["model"], "Gaussian pass should depend on model pass to access depth buffer") } - func testBuildGameModeGraph_PreCompDependsOnGaussian() { + func testBuildGameModeGraph_PreCompDependsOnGaussian() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() let precompDeps = graph["precomp"]?.dependencies.sorted() ?? [] XCTAssertTrue(precompDeps.contains("gaussian"), "Pre-composite pass should depend on gaussian pass") } - func testBuildGameModeGraph_GaussianHasExecutionFunction() { + func testBuildGameModeGraph_GaussianHasExecutionFunction() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["gaussian"]?.execute, "Gaussian pass should have an execution function") } - func testBuildGameModeGraph_GaussianInAllRenderModes() { + func testBuildGameModeGraph_GaussianInAllRenderModes() throws { // Test that gaussian pass exists in all render modes let modes: [(UntoldImmersionMode, Bool, String)] = [ (.none, true, "environment"), @@ -602,7 +1254,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderInfo.immersionStyle = immersionStyle renderEnvironment = useEnvironment - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["gaussian"], "Gaussian pass should exist in \(description) mode") @@ -615,7 +1267,7 @@ final class RenderGraphBuilderTest: BaseRenderSetup { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() let sorted = try topologicalSortGraph(graph: graph) let order = sorted.map(\.id) @@ -641,6 +1293,1977 @@ final class RenderGraphBuilderTest: BaseRenderSetup { "Gaussian pass must come before pre-composite pass") } + func testRenderExtensionRegisteredThroughRenderingAPIInsertsAfterTransparency() throws { + renderInfo.immersionStyle = .none + renderEnvironment = true + setRendering(.extensions(.register( + TestStageRenderExtension(passID: "test.afterTransparency", stage: .afterTransparency) + ))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNotNil(graph["test.afterTransparency"], "Extension pass should be added to the graph") + XCTAssertEqual(graph["test.afterTransparency"]?.dependencies, ["transparency"]) + XCTAssertEqual(graph["wireframe"]?.dependencies, ["test.afterTransparency"], + "Wireframe should wait for afterTransparency extension passes") + + let sorted = try topologicalSortGraph(graph: graph) + let order = sorted.map(\.id) + assertTopologicalConstraints(order: order, constraints: [ + ("transparency", "test.afterTransparency"), + ("test.afterTransparency", "wireframe"), + ]) + } + + func testRenderExtensionBeforePostProcessRewiresPostProcessEntry() throws { + renderInfo.immersionStyle = .none + renderEnvironment = true + DepthOfFieldParams.shared.enabled = true + defer { DepthOfFieldParams.shared.enabled = false } + + setRendering(.extensions(.register( + TestStageRenderExtension(passID: "test.beforePostProcess", stage: .beforePostProcess) + ))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertEqual(graph["test.beforePostProcess"]?.dependencies, ["spatialDebug"]) + XCTAssertEqual(graph["depthOfField"]?.dependencies, ["test.beforePostProcess"], + "Post-processing should start after beforePostProcess extension passes") + + let sorted = try topologicalSortGraph(graph: graph) + let order = sorted.map(\.id) + assertTopologicalConstraints(order: order, constraints: [ + ("spatialDebug", "test.beforePostProcess"), + ("test.beforePostProcess", "depthOfField"), + ]) + } + + func testRenderExtensionBeforeOutputRewiresOutputTransform() throws { + renderInfo.immersionStyle = .none + renderEnvironment = true + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + setRendering(.extensions(.register( + TestStageRenderExtension(passID: "test.beforeOutput", stage: .beforeOutput) + ))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertEqual(graph["test.beforeOutput"]?.dependencies, ["look"]) + XCTAssertEqual(graph["outputTransform"]?.dependencies, ["test.beforeOutput"], + "Output transform should wait for beforeOutput extension passes") + + let sorted = try topologicalSortGraph(graph: graph) + let order = sorted.map(\.id) + assertTopologicalConstraints(order: order, constraints: [ + ("look", "test.beforeOutput"), + ("test.beforeOutput", "outputTransform"), + ]) + } + + func testRenderStageContractContainsOnlyResolvedStages() { + XCTAssertEqual(RenderStage.allCases, [ + .afterOpaqueLighting, + .beforeTransparency, + .afterTransparency, + .beforePostProcess, + .afterPostProcess, + .beforeComposite, + .beforeLook, + .beforeOutput, + ]) + } + + func testReservedPassIDsCoverEveryGameModeGraphVariant() throws { + let originalEnvironment = renderEnvironment + let originalBypass = bypassPostProcessing + let originalAA = antiAliasingMode + let originalDebugMode = renderDebugViewMode + let originalBloom = BloomThresholdParams.shared.enabled + let originalVignette = VignetteParams.shared.enabled + let originalChromatic = ChromaticAberrationParams.shared.enabled + let originalDepthOfField = DepthOfFieldParams.shared.enabled + defer { + renderEnvironment = originalEnvironment + bypassPostProcessing = originalBypass + antiAliasingMode = originalAA + renderDebugViewMode = originalDebugMode + BloomThresholdParams.shared.enabled = originalBloom + VignetteParams.shared.enabled = originalVignette + ChromaticAberrationParams.shared.enabled = originalChromatic + DepthOfFieldParams.shared.enabled = originalDepthOfField + } + + var observedPassIDs = Set() + func captureGraphPassIDs() throws { + let (graph, _) = try buildGameModeGraph() + observedPassIDs.formUnion(graph.keys) + } + + bypassPostProcessing = false + BloomThresholdParams.shared.enabled = true + VignetteParams.shared.enabled = true + ChromaticAberrationParams.shared.enabled = true + DepthOfFieldParams.shared.enabled = true + renderDebugViewMode = .lit + + for environmentEnabled in [false, true] { + renderEnvironment = environmentEnabled + for mode in [AntiAliasingMode.none, .fxaa, .smaa, .msaa] { + antiAliasingMode = mode + try captureGraphPassIDs() + } + } + + antiAliasingMode = .none + for mode in [ + RenderDebugViewMode.fxaaEdgeDebug, + .smaaEdges, + .smaaBlend, + .smaaDifference, + ] { + renderDebugViewMode = mode + try captureGraphPassIDs() + } + + renderDebugViewMode = .lit + bypassPostProcessing = true + try captureGraphPassIDs() + + bypassPostProcessing = false + BloomThresholdParams.shared.enabled = false + VignetteParams.shared.enabled = false + ChromaticAberrationParams.shared.enabled = false + DepthOfFieldParams.shared.enabled = false + try captureGraphPassIDs() + + XCTAssertEqual(observedPassIDs, gameModeReservedPassIDs) + } + + func testEverySupportedRenderExtensionStageParticipatesInGraphOrder() throws { + renderInfo.immersionStyle = .none + renderEnvironment = true + bypassPostProcessing = true + antiAliasingMode = .none + defer { + bypassPostProcessing = false + antiAliasingMode = .fxaa + } + + let stagedPasses: [(id: String, stage: RenderStage)] = [ + ("test.stage.afterOpaqueLighting", .afterOpaqueLighting), + ("test.stage.beforeTransparency", .beforeTransparency), + ("test.stage.afterTransparency", .afterTransparency), + ("test.stage.beforePostProcess", .beforePostProcess), + ("test.stage.afterPostProcess", .afterPostProcess), + ("test.stage.beforeComposite", .beforeComposite), + ("test.stage.beforeLook", .beforeLook), + ("test.stage.beforeOutput", .beforeOutput), + ] + for stagedPass in stagedPasses { + setRendering(.extensions(.register( + TestStageRenderExtension(passID: stagedPass.id, stage: stagedPass.stage) + ))) + } + + let (graph, _) = try buildGameModeGraph() + for stagedPass in stagedPasses { + XCTAssertNotNil(graph[stagedPass.id], "Missing pass for \(stagedPass.stage.rawValue)") + } + + let order = try topologicalSortGraph(graph: graph).map(\.id) + assertTopologicalConstraints(order: order, constraints: [ + ("lightPass", "test.stage.afterOpaqueLighting"), + ("test.stage.afterOpaqueLighting", "test.stage.beforeTransparency"), + ("test.stage.beforeTransparency", "transparency"), + ("transparency", "test.stage.afterTransparency"), + ("test.stage.afterTransparency", "wireframe"), + ("spatialDebug", "test.stage.beforePostProcess"), + ("test.stage.beforePostProcess", "postProcessBypass"), + ("postProcessBypass", "test.stage.afterPostProcess"), + ("test.stage.afterPostProcess", "test.stage.beforeComposite"), + ("test.stage.beforeComposite", "precomp"), + ("precomp", "test.stage.beforeLook"), + ("test.stage.beforeLook", "look"), + ("look", "test.stage.beforeOutput"), + ("test.stage.beforeOutput", "outputTransform"), + ]) + } + + func testSupportedRenderExtensionStagesResolveAcrossImmersionModes() throws { + let immersionModes: [UntoldImmersionMode] = [.none, .mixed, .full, .ar] + bypassPostProcessing = true + antiAliasingMode = .none + defer { + bypassPostProcessing = false + antiAliasingMode = .fxaa + } + + for (modeIndex, immersionMode) in immersionModes.enumerated() { + RenderExtensionRegistry.shared.removeAll() + renderInfo.immersionStyle = immersionMode + + let passIDs = RenderStage.allCases.map { stage in + "test.mode\(modeIndex).\(stage.rawValue)" + } + for (stage, passID) in zip(RenderStage.allCases, passIDs) { + setRendering(.extensions(.register( + TestStageRenderExtension(passID: passID, stage: stage) + ))) + } + + let (graph, _) = try buildGameModeGraph() + for (stage, passID) in zip(RenderStage.allCases, passIDs) { + XCTAssertNotNil( + graph[passID], + "Missing \(stage.rawValue) pass in immersion mode \(immersionMode)" + ) + } + } + } + + func testRenderExtensionsPreserveRegistrationOrderWithinStage() throws { + let firstPassID = "test.sameStage.first" + let secondPassID = "test.sameStage.second" + setRendering(.extensions(.register( + TestStageRenderExtension(passID: firstPassID, stage: .afterTransparency) + ))) + setRendering(.extensions(.register( + TestStageRenderExtension(passID: secondPassID, stage: .afterTransparency) + ))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertEqual(graph[firstPassID]?.dependencies, ["transparency"]) + XCTAssertEqual(graph[secondPassID]?.dependencies, [firstPassID]) + XCTAssertEqual(graph["wireframe"]?.dependencies, [secondPassID]) + } + + func testRenderGraphBuildRejectsUnresolvedStagePasses() { + var builder = RenderGraphBuilder() + XCTAssertTrue(builder.addPass( + id: "test.unresolved.stage", + stage: .beforeOutput, + execute: nil + )) + + XCTAssertThrowsError(try builder.build()) { error in + XCTAssertEqual( + error as? RenderGraphError, + .unresolvedStages([.beforeOutput]) + ) + } + } + + func testRenderGraphBuilderRejectsDuplicatePendingStagePassID() { + var builder = RenderGraphBuilder() + + XCTAssertTrue(builder.addPass( + id: "test.duplicate.stage", + stage: .afterTransparency, + execute: nil + )) + XCTAssertFalse(builder.addPass( + id: "test.duplicate.stage", + stage: .beforeOutput, + execute: nil + )) + + XCTAssertThrowsError(try builder.build()) { error in + XCTAssertEqual( + error as? RenderGraphError, + .duplicatePassID("test.duplicate.stage") + ) + } + } + + func testRenderGraphBuilderRejectsDuplicateImmediatePassID() { + var builder = RenderGraphBuilder() + XCTAssertTrue(builder.addPass(id: "test.duplicate", dependencies: [], execute: nil)) + XCTAssertFalse(builder.addPass(id: "test.duplicate", dependencies: [], execute: nil)) + + XCTAssertThrowsError(try builder.build()) { error in + XCTAssertEqual( + error as? RenderGraphError, + .duplicatePassID("test.duplicate") + ) + } + } + + func testRenderGraphBuilderRejectsMissingDependency() { + var builder = RenderGraphBuilder() + builder.addPass( + id: "test.dependent", + dependencies: ["test.missing"], + execute: nil + ) + + XCTAssertThrowsError(try builder.build()) { error in + XCTAssertEqual( + error as? RenderGraphError, + .missingDependency( + passID: "test.dependent", + dependencyID: "test.missing" + ) + ) + } + } + + func testDirectRenderGraphBuilderStillRejectsMissingResourceUsage() { + let textureID: RenderTextureResourceID = "test.usage.direct.missing" + var builder = RenderGraphBuilder() + builder.addPass( + id: "test.usage.direct.pass", + stage: .beforeOutput, + resources: [.texture(textureID, access: .read)], + execute: nil + ) + + XCTAssertThrowsError(try builder.build()) { error in + XCTAssertEqual( + error as? RenderGraphError, + .missingResource( + passID: "test.usage.direct.pass", + kind: .texture, + resourceID: textureID.rawValue + ) + ) + } + } + + func testGameModeGraphRejectsExtensionWithInvalidDependencyAndRebuilds() throws { + let renderExtension = TestInvalidDependencyRenderExtension() + setRendering(.extensions(.register(renderExtension))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph[renderExtension.passID]) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertEqual( + RenderExtensionRegistry.shared.graphValidationErrors( + forExtensionID: renderExtension.id + ), + [ + .missingDependency( + passID: renderExtension.passID, + dependencyID: renderExtension.dependencyID + ), + ] + ) + } + + func testReservedEnginePassIDRejectsOnlyConflictingExtension() throws { + let renderExtension = TestLifecycleRenderExtension( + id: "test.pass.reserved.extension", + artifactSuffix: "reserved.pass", + passID: "transparency", + shaderLibrary: renderInfo.library + ) + setRendering(.extensions(.register(renderExtension))) + assertLifecycleArtifactsPresent(renderExtension) + + let graph = try buildExecutableGameModeGraph() + + XCTAssertNotNil(graph.passesByID["transparency"]) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + assertLifecycleArtifactsAbsent(renderExtension) + XCTAssertEqual( + RenderExtensionRegistry.shared.registrationConflicts(forExtensionID: renderExtension.id), + [ + RenderExtensionArtifactConflict( + kind: .renderPass, + artifactID: "transparency", + requestedOwnerID: renderExtension.id, + existingOwnerID: nil + ), + ] + ) + } + + func testDuplicateProviderPassIDRollsBackRejectedExtensionPasses() throws { + let first = TestStageRenderExtension( + id: "test.pass.owner.first", + passID: "test.pass.shared", + stage: .afterTransparency + ) + let second = TestMultiPassConflictRenderExtension( + id: "test.pass.owner.second", + uniquePassID: "test.pass.second.unique", + conflictingPassID: first.passID + ) + setRendering(.extensions(.register(first))) + setRendering(.extensions(.register(second))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNotNil(graph[first.passID]) + XCTAssertNil(graph[second.uniquePassID]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [first.id]) + XCTAssertEqual( + RenderExtensionRegistry.shared.registrationConflicts(forExtensionID: second.id), + [ + RenderExtensionArtifactConflict( + kind: .renderPass, + artifactID: first.passID, + requestedOwnerID: second.id, + existingOwnerID: first.id + ), + ] + ) + } + + func testPassCollisionRollsBackOwnedResourceValidationErrors() throws { + let first = TestStageRenderExtension( + id: "test.usage.rollback.first", + passID: "test.usage.rollback.shared", + stage: .afterTransparency + ) + let second = TestInvalidResourceThenConflictRenderExtension( + id: "test.usage.rollback.second", + uniquePassID: "test.usage.rollback.unique", + conflictingPassID: first.passID, + missingTextureID: "test.usage.rollback.missing" + ) + setRendering(.extensions(.register(first))) + setRendering(.extensions(.register(second))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNotNil(graph[first.passID]) + XCTAssertNil(graph[second.uniquePassID]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [first.id]) + } + + func testRenderGraphIsolatesMissingExtensionResourceUsage() throws { + let textureID: RenderTextureResourceID = "test.usage.missing.texture" + let renderExtension = TestResourceUsageRenderExtension( + id: "test.usage.missing", + passID: "test.usage.missing.pass", + usages: [.texture(textureID, access: .read)] + ) + setRendering(.extensions(.register(renderExtension))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph["test.usage.missing.pass"]) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertEqual( + RenderExtensionRegistry.shared.graphValidationErrors(forExtensionID: renderExtension.id), + [ + .missingResource( + passID: "test.usage.missing.pass", + kind: .texture, + resourceID: textureID.rawValue + ), + ] + ) + } + + func testRenderGraphIsolatesCrossExtensionResourceUsage() throws { + let textureID: RenderTextureResourceID = "test.usage.provider.texture" + let provider = TestResourceUsageRenderExtension( + id: "test.usage.provider", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ] + ) + let consumer = TestResourceUsageRenderExtension( + id: "test.usage.consumer", + passID: "test.usage.consumer.pass", + usages: [.texture(textureID, access: .read)] + ) + setRendering(.extensions(.register(provider))) + setRendering(.extensions(.register(consumer))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph["test.usage.consumer.pass"]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [provider.id]) + XCTAssertNotNil(getRenderResource(textureID)) + XCTAssertEqual( + RenderExtensionRegistry.shared.graphValidationErrors(forExtensionID: consumer.id), + [ + .inaccessibleResource( + passID: "test.usage.consumer.pass", + kind: .texture, + resourceID: textureID.rawValue, + requestedOwnerID: consumer.id, + existingOwnerID: provider.id + ), + ] + ) + } + + func testRenderGraphIsolatesIncompatibleTextureUsageAndReleasesArtifacts() throws { + let textureID: RenderTextureResourceID = "test.usage.incompatible.texture" + let renderExtension = TestResourceUsageRenderExtension( + id: "test.usage.incompatible.texture", + passID: "test.usage.incompatible.texture.pass", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + usages: [.texture(textureID, access: [.write, .renderTarget])] + ) + setRendering(.extensions(.register(renderExtension))) + + XCTAssertNotNil(getRenderResource(textureID)) + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph["test.usage.incompatible.texture.pass"]) + XCTAssertNil(getRenderResource(textureID)) + XCTAssertEqual(RenderResourceRegistry.shared.textureState(textureID), .released) + XCTAssertEqual( + RenderExtensionRegistry.shared.graphValidationErrors(forExtensionID: renderExtension.id), + [ + .incompatibleResourceUsage( + passID: "test.usage.incompatible.texture.pass", + kind: .texture, + resourceID: textureID.rawValue, + access: [.write, .renderTarget] + ), + ] + ) + } + + func testRenderGraphIsolatesRenderTargetBufferUsage() throws { + let bufferID: RenderBufferResourceID = "test.usage.incompatible.buffer" + let renderExtension = TestResourceUsageRenderExtension( + id: "test.usage.incompatible.buffer", + passID: "test.usage.incompatible.buffer.pass", + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: bufferID, length: 64), + ], + usages: [.buffer(bufferID, access: .renderTarget)] + ) + setRendering(.extensions(.register(renderExtension))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph["test.usage.incompatible.buffer.pass"]) + XCTAssertNil(getRenderResource(bufferID)) + XCTAssertEqual( + RenderExtensionRegistry.shared.graphValidationErrors(forExtensionID: renderExtension.id), + [ + .incompatibleResourceUsage( + passID: "test.usage.incompatible.buffer.pass", + kind: .buffer, + resourceID: bufferID.rawValue, + access: .renderTarget + ), + ] + ) + } + + func testGraphValidationFailureDoesNotRemoveHealthyExtensionPasses() throws { + let healthy = TestStageRenderExtension( + id: "test.usage.isolation.healthy", + passID: "test.usage.isolation.healthy.pass", + stage: .beforeOutput + ) + let invalid = TestResourceUsageRenderExtension( + id: "test.usage.isolation.invalid", + passID: "test.usage.isolation.invalid.pass", + usages: [.buffer("test.usage.isolation.missing", access: .read)] + ) + setRendering(.extensions(.register(healthy))) + setRendering(.extensions(.register(invalid))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNotNil(graph[healthy.passID]) + XCTAssertNil(graph["test.usage.isolation.invalid.pass"]) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [healthy.id]) + } + + func testGraphValidationReportsAllErrorsAndRollsBackAllExtensionPasses() throws { + let firstTextureID: RenderTextureResourceID = "test.usage.multiple.first" + let secondBufferID: RenderBufferResourceID = "test.usage.multiple.second" + let firstInvalidExtension = TestResourceUsageRenderExtension( + id: "test.usage.multiple", + passID: "test.usage.multiple.first.pass", + usages: [.texture(firstTextureID, access: .read)] + ) + let secondInvalidExtension = TestResourceUsageRenderExtension( + id: firstInvalidExtension.id, + passID: "test.usage.multiple.second.pass", + usages: [.buffer(secondBufferID, access: .read)] + ) + + var builder = RenderGraphBuilder() + builder.beginExtensionRegistration(id: firstInvalidExtension.id) + firstInvalidExtension.buildGraph(&builder, context: makeRenderGraphBuildContext()) + secondInvalidExtension.buildGraph(&builder, context: makeRenderGraphBuildContext()) + let report = builder.endExtensionRegistration() + + XCTAssertEqual( + report.validationErrors, + [ + .missingResource( + passID: "test.usage.multiple.first.pass", + kind: .texture, + resourceID: firstTextureID.rawValue + ), + .missingResource( + passID: "test.usage.multiple.second.pass", + kind: .buffer, + resourceID: secondBufferID.rawValue + ), + ] + ) + XCTAssertTrue(report.conflicts.isEmpty) + XCTAssertTrue(try builder.build().isEmpty) + } + + func testSuccessfulReregistrationClearsGraphValidationErrors() throws { + let extensionID = "test.usage.diagnostics.reregister" + let invalid = TestResourceUsageRenderExtension( + id: extensionID, + passID: "test.usage.diagnostics.invalid.pass", + usages: [.texture("test.usage.diagnostics.missing", access: .read)] + ) + setRendering(.extensions(.register(invalid))) + _ = try buildGameModeGraph() + XCTAssertFalse( + RenderExtensionRegistry.shared.graphValidationErrors(forExtensionID: extensionID).isEmpty + ) + + let valid = TestStageRenderExtension( + id: extensionID, + passID: "test.usage.diagnostics.valid.pass", + stage: .beforeOutput + ) + XCTAssertEqual(RenderExtensionRegistry.shared.register(valid), .registered) + + XCTAssertTrue( + RenderExtensionRegistry.shared.graphValidationErrors(forExtensionID: extensionID).isEmpty + ) + } + + func testRenderPassContextExposesOnlyDeclaredResourceUsages() throws { + let declaredTextureID: RenderTextureResourceID = "test.usage.declared.texture" + let undeclaredTextureID: RenderTextureResourceID = "test.usage.undeclared.texture" + let declaredBufferID: RenderBufferResourceID = "test.usage.declared.buffer" + let undeclaredBufferID: RenderBufferResourceID = "test.usage.undeclared.buffer" + let usages: [RenderGraphResourceUsage] = [ + .texture(declaredTextureID, access: [.read, .renderTarget]), + .buffer(declaredBufferID, access: .write), + ] + let renderExtension = TestResourceUsageRenderExtension( + id: "test.usage.scoped", + passID: "test.usage.scoped.pass", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: declaredTextureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: [.shaderRead, .renderTarget] + ), + RenderExtensionTextureDescriptor( + id: undeclaredTextureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: declaredBufferID, length: 64), + RenderExtensionBufferDescriptor(id: undeclaredBufferID, length: 64), + ], + usages: usages, + observedTextureIDs: [declaredTextureID, undeclaredTextureID], + observedBufferIDs: [declaredBufferID, undeclaredBufferID] + ) + setRendering(.extensions(.register(renderExtension))) + + let (graph, _) = try buildGameModeGraph() + XCTAssertEqual(graph["test.usage.scoped.pass"]?.resourceUsages, usages) + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph["test.usage.scoped.pass"]?.execute?(commandBuffer) + + XCTAssertEqual(renderExtension.observedTextures[declaredTextureID], true) + XCTAssertEqual(renderExtension.observedTextures[undeclaredTextureID], false) + XCTAssertEqual(renderExtension.observedBuffers[declaredBufferID], true) + XCTAssertEqual(renderExtension.observedBuffers[undeclaredBufferID], false) + } + + func testRenderExtensionRegistersFixedTextureResource() { + let textureID: RenderTextureResourceID = "test.fixed.texture" + + setRendering(.extensions(.register( + TestResourceRenderExtension( + textureID: textureID.rawValue, + size: .fixed(width: 128, height: 64) + ) + ))) + + let texture = getRenderResource(textureID) + XCTAssertNotNil(texture, "Extension texture should be created when registered after renderer initialization") + XCTAssertEqual(texture?.width, 128) + XCTAssertEqual(texture?.height, 64) + XCTAssertEqual(texture?.pixelFormat, .rgba16Float) + } + + func testRenderExtensionViewportTextureResourceRecreatesOnResize() { + let textureID = "test.viewport.texture" + + setRendering(.extensions(.register( + TestResourceRenderExtension( + textureID: textureID, + size: .viewportScale(0.5) + ) + ))) + + var texture = getRenderResource(.texture(textureID)) + XCTAssertEqual(texture?.width, windowWidth / 2) + XCTAssertEqual(texture?.height, windowHeight / 2) + + renderInfo.viewPort = simd_float2(640, 360) + renderer.initSizeableResources() + + texture = getRenderResource(.texture(textureID)) + XCTAssertEqual(texture?.width, 320) + XCTAssertEqual(texture?.height, 180) + } + + func testRenderPassContextExposesExtensionResources() throws { + let extensionInstance = TestResourceRenderExtension( + textureID: "test.context.texture", + passID: "test.context.pass", + size: .fixed(width: 32, height: 16) + ) + setRendering(.extensions(.register(extensionInstance))) + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph["test.context.pass"]?.execute?(commandBuffer) + + XCTAssertEqual(extensionInstance.observedTextureSize, SIMD2(32, 16)) + } + + func testRenderExtensionUnregisterRemovesOwnedTextureResources() { + let textureID = "test.unregister.texture" + let extensionInstance = TestResourceRenderExtension( + textureID: textureID, + size: .fixed(width: 8, height: 8) + ) + + setRendering(.extensions(.register(extensionInstance))) + XCTAssertNotNil(getRenderResource(.texture(textureID))) + + setRendering(.extensions(.unregister(extensionInstance.id))) + XCTAssertNil(getRenderResource(.texture(textureID)), + "Unregistering an extension should remove its owned texture resources") + XCTAssertEqual( + RenderResourceRegistry.shared.textureState(RenderTextureResourceID(textureID)), + .released + ) + } + + func testRenderExtensionRegistersTypedBufferResource() throws { + let bufferID: RenderBufferResourceID = "test.typed.buffer" + let extensionInstance = TestBufferResourceRenderExtension( + bufferID: bufferID, + length: 256 + ) + + setRendering(.extensions(.register(extensionInstance))) + + let buffer = getRenderResource(bufferID) + XCTAssertEqual(buffer?.length, 256) + XCTAssertEqual(buffer?.label, bufferID.rawValue) + + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph[extensionInstance.passID]?.execute?(commandBuffer) + XCTAssertEqual(extensionInstance.observedBufferLength, 256) + } + + func testRenderExtensionUnregisterRemovesOwnedBufferResources() { + let bufferID: RenderBufferResourceID = "test.unregister.buffer" + let extensionInstance = TestBufferResourceRenderExtension( + bufferID: bufferID, + length: 64 + ) + + setRendering(.extensions(.register(extensionInstance))) + XCTAssertNotNil(getRenderResource(bufferID)) + + setRendering(.extensions(.unregister(extensionInstance.id))) + XCTAssertNil(getRenderResource(bufferID)) + XCTAssertEqual(RenderResourceRegistry.shared.bufferState(bufferID), .released) + } + + func testInvalidResourceDeclarationRejectsEntireTransaction() { + let textureID: RenderTextureResourceID = "test.transaction.valid.texture" + let invalidBufferID: RenderBufferResourceID = "test.transaction.invalid.buffer" + let renderExtension = TestTransactionalResourceRenderExtension( + id: "test.transaction.invalid", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 16, height: 16), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: invalidBufferID, length: 0), + ] + ) + + let result = RenderExtensionRegistry.shared.register(renderExtension) + let expectedError = RenderExtensionResourceValidationError.invalidBufferLength( + id: invalidBufferID.rawValue, + length: 0 + ) + + XCTAssertEqual( + result, + .rejectedResources(conflicts: [], validationErrors: [expectedError]) + ) + XCTAssertNil(getRenderResource(textureID)) + XCTAssertNil(getRenderResource(invalidBufferID)) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertEqual( + RenderExtensionRegistry.shared.resourceValidationErrors(forExtensionID: renderExtension.id), + [expectedError] + ) + } + + func testDuplicateResourceIDRejectsEntireTransaction() { + let textureID: RenderTextureResourceID = "test.transaction.unique.texture" + let bufferID: RenderBufferResourceID = "test.transaction.duplicate.buffer" + let renderExtension = TestTransactionalResourceRenderExtension( + id: "test.transaction.duplicate", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: bufferID, length: 64), + RenderExtensionBufferDescriptor(id: bufferID, length: 128), + ] + ) + let expectedConflict = RenderExtensionArtifactConflict( + kind: .buffer, + artifactID: bufferID.rawValue, + requestedOwnerID: renderExtension.id, + existingOwnerID: renderExtension.id + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension), + .rejected([expectedConflict]) + ) + XCTAssertNil(getRenderResource(textureID)) + XCTAssertNil(getRenderResource(bufferID)) + XCTAssertEqual( + RenderExtensionRegistry.shared.registrationConflicts(forExtensionID: renderExtension.id), + [expectedConflict] + ) + } + + func testResourceCollisionDoesNotCommitEarlierDeclarations() { + let sharedTextureID: RenderTextureResourceID = "test.transaction.shared.texture" + let uniqueBufferID: RenderBufferResourceID = "test.transaction.second.unique.buffer" + let first = TestTransactionalResourceRenderExtension( + id: "test.transaction.first", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: sharedTextureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ] + ) + let second = TestTransactionalResourceRenderExtension( + id: "test.transaction.second", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: sharedTextureID, + size: .fixed(width: 32, height: 32), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: uniqueBufferID, length: 64), + ] + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(first), .registered) + let originalTexture = getRenderResource(sharedTextureID) + let result = RenderExtensionRegistry.shared.register(second) + + XCTAssertEqual( + result, + .rejected([ + RenderExtensionArtifactConflict( + kind: .texture, + artifactID: sharedTextureID.rawValue, + requestedOwnerID: second.id, + existingOwnerID: first.id + ), + ]) + ) + XCTAssertTrue(getRenderResource(sharedTextureID) === originalTexture) + XCTAssertNil(getRenderResource(uniqueBufferID)) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [first.id]) + } + + func testInvalidReplacementPreservesPreviousResourceObjects() { + let ownerID = "test.transaction.replacement" + let originalTextureID: RenderTextureResourceID = "test.transaction.original.texture" + let originalBufferID: RenderBufferResourceID = "test.transaction.original.buffer" + let replacementTextureID: RenderTextureResourceID = "test.transaction.replacement.texture" + let invalidBufferID: RenderBufferResourceID = "test.transaction.replacement.invalid" + let original = TestTransactionalResourceRenderExtension( + id: ownerID, + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: originalTextureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: originalBufferID, length: 64), + ] + ) + let replacement = TestTransactionalResourceRenderExtension( + id: ownerID, + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: replacementTextureID, + size: .fixed(width: 16, height: 16), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: invalidBufferID, length: -1), + ] + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(original), .registered) + let originalTexture = getRenderResource(originalTextureID) + let originalBuffer = getRenderResource(originalBufferID) + + guard case .rejectedResources = RenderExtensionRegistry.shared.register(replacement) else { + XCTFail("Expected invalid replacement resources to be rejected") + return + } + + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [ownerID]) + XCTAssertTrue(getRenderResource(originalTextureID) === originalTexture) + XCTAssertTrue(getRenderResource(originalBufferID) === originalBuffer) + XCTAssertNil(getRenderResource(replacementTextureID)) + XCTAssertNil(getRenderResource(invalidBufferID)) + XCTAssertEqual(original.resourceRegistrationCount, 1) + } + + func testResourceDeclarationsCommitBeforeMetalAllocation() { + let bufferID: RenderBufferResourceID = "test.transaction.deferred.buffer" + let renderExtension = TestTransactionalResourceRenderExtension( + id: "test.transaction.deferred", + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: bufferID, length: 96), + ] + ) + let device = renderInfo.device + renderInfo.device = nil + defer { renderInfo.device = device } + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + XCTAssertNil(getRenderResource(bufferID)) + XCTAssertEqual(RenderResourceRegistry.shared.bufferState(bufferID), .declared) + XCTAssertEqual(renderExtension.resourceRegistrationCount, 1) + + renderInfo.device = device + RenderResourceRegistry.shared.recreateResources() + + XCTAssertEqual(getRenderResource(bufferID)?.length, 96) + XCTAssertEqual(RenderResourceRegistry.shared.bufferState(bufferID), .allocated) + XCTAssertEqual(renderExtension.resourceRegistrationCount, 1) + } + + func testViewportTextureRemainsDeclaredUntilViewportIsValid() { + let textureID: RenderTextureResourceID = "test.lifecycle.deferred-viewport.texture" + let renderExtension = TestTransactionalResourceRenderExtension( + id: "test.lifecycle.deferred-viewport", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .viewportScale(0.5), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ] + ) + let viewport = renderInfo.viewPort + renderInfo.viewPort = .zero + defer { renderInfo.viewPort = viewport } + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + XCTAssertNil(getRenderResource(textureID)) + XCTAssertEqual(RenderResourceRegistry.shared.textureState(textureID), .declared) + + renderInfo.viewPort = viewport + RenderResourceRegistry.shared.recreateResources() + + XCTAssertNotNil(getRenderResource(textureID)) + XCTAssertEqual(RenderResourceRegistry.shared.textureState(textureID), .allocated) + } + + func testViewportResizeReallocatesOnlyAffectedExtensionResources() { + let fixedTextureID: RenderTextureResourceID = "test.lifecycle.fixed.texture" + let viewportTextureID: RenderTextureResourceID = "test.lifecycle.viewport.texture" + let bufferID: RenderBufferResourceID = "test.lifecycle.buffer" + let allocator = TestRenderExtensionResourceAllocator() + let previousAllocator = RenderResourceRegistry.shared.replaceAllocatorForTesting(allocator) + defer { + _ = RenderResourceRegistry.shared.replaceAllocatorForTesting(previousAllocator) + } + let renderExtension = TestTransactionalResourceRenderExtension( + id: "test.lifecycle.selective-resize", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: fixedTextureID, + size: .fixed(width: 32, height: 16), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + RenderExtensionTextureDescriptor( + id: viewportTextureID, + size: .viewportScale(0.5), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: bufferID, length: 64), + ] + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + let fixedTexture = getRenderResource(fixedTextureID) + let viewportTexture = getRenderResource(viewportTextureID) + let buffer = getRenderResource(bufferID) + + renderInfo.viewPort = simd_float2(640, 360) + RenderResourceRegistry.shared.recreateResources() + + XCTAssertTrue(getRenderResource(fixedTextureID) === fixedTexture) + XCTAssertFalse(getRenderResource(viewportTextureID) === viewportTexture) + XCTAssertTrue(getRenderResource(bufferID) === buffer) + XCTAssertEqual(getRenderResource(viewportTextureID)?.width, 320) + XCTAssertEqual(getRenderResource(viewportTextureID)?.height, 180) + XCTAssertEqual(allocator.textureAllocationCounts[fixedTextureID], 1) + XCTAssertEqual(allocator.textureAllocationCounts[viewportTextureID], 2) + XCTAssertEqual(allocator.bufferAllocationCounts[bufferID], 1) + } + + func testAllocationFailuresAreStructuredAndRetryable() { + let textureID: RenderTextureResourceID = "test.lifecycle.failed.texture" + let bufferID: RenderBufferResourceID = "test.lifecycle.failed.buffer" + let ownerID = "test.lifecycle.failed" + let allocator = TestRenderExtensionResourceAllocator() + allocator.failingTextureIDs = [textureID] + allocator.failingBufferIDs = [bufferID] + let previousAllocator = RenderResourceRegistry.shared.replaceAllocatorForTesting(allocator) + defer { + _ = RenderResourceRegistry.shared.replaceAllocatorForTesting(previousAllocator) + } + let renderExtension = TestTransactionalResourceRenderExtension( + id: ownerID, + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 16, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: bufferID, length: 128), + ] + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + XCTAssertNil(getRenderResource(textureID)) + XCTAssertNil(getRenderResource(bufferID)) + XCTAssertEqual(RenderResourceRegistry.shared.textureState(textureID), .invalidated) + XCTAssertEqual(RenderResourceRegistry.shared.bufferState(bufferID), .invalidated) + XCTAssertEqual( + RenderExtensionRegistry.shared.resourceAllocationErrors(forExtensionID: ownerID), + [ + RenderExtensionResourceAllocationError( + kind: .buffer, + resourceID: bufferID.rawValue, + ownerID: ownerID, + failure: .bufferCreationFailed(length: 128) + ), + RenderExtensionResourceAllocationError( + kind: .texture, + resourceID: textureID.rawValue, + ownerID: ownerID, + failure: .textureCreationFailed(width: 16, height: 8) + ), + ] + ) + + allocator.failingTextureIDs = [] + allocator.failingBufferIDs = [] + RenderResourceRegistry.shared.recreateResources() + + XCTAssertNotNil(getRenderResource(textureID)) + XCTAssertNotNil(getRenderResource(bufferID)) + XCTAssertEqual(RenderResourceRegistry.shared.textureState(textureID), .allocated) + XCTAssertEqual(RenderResourceRegistry.shared.bufferState(bufferID), .allocated) + XCTAssertTrue( + RenderExtensionRegistry.shared.resourceAllocationErrors(forExtensionID: ownerID).isEmpty + ) + XCTAssertEqual(allocator.textureAllocationCounts[textureID], 2) + XCTAssertEqual(allocator.bufferAllocationCounts[bufferID], 2) + } + + func testRemovingAllExtensionsReleasesResourceLifecycleState() { + let textureID: RenderTextureResourceID = "test.lifecycle.remove-all.texture" + let bufferID: RenderBufferResourceID = "test.lifecycle.remove-all.buffer" + let renderExtension = TestTransactionalResourceRenderExtension( + id: "test.lifecycle.remove-all", + textureDescriptors: [ + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 8, height: 8), + pixelFormat: .rgba16Float, + usage: .shaderRead + ), + ], + bufferDescriptors: [ + RenderExtensionBufferDescriptor(id: bufferID, length: 32), + ] + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + RenderExtensionRegistry.shared.removeAll() + + XCTAssertEqual(RenderResourceRegistry.shared.textureState(textureID), .released) + XCTAssertEqual(RenderResourceRegistry.shared.bufferState(bufferID), .released) + XCTAssertNil(getRenderResource(textureID)) + XCTAssertNil(getRenderResource(bufferID)) + } + + func testRenderExtensionRegistersComputePipeline() { + let computeType: ComputePipelineType = "test.compute.pipeline" + + setRendering(.extensions(.register( + TestComputeRenderExtension(computeType: computeType) + ))) + + let pipeline = ComputePipelineManager.shared.pipeline(for: computeType) + XCTAssertNotNil(pipeline) + XCTAssertTrue(pipeline?.success == true) + XCTAssertEqual(pipeline?.name, "Test Compute Pipeline") + } + + func testRenderPassContextExposesExtensionComputePipelines() throws { + let computeType: ComputePipelineType = "test.context.compute.pipeline" + let extensionInstance = TestComputeRenderExtension( + computeType: computeType, + passID: "test.context.compute.pass" + ) + + setRendering(.extensions(.register(extensionInstance))) + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph["test.context.compute.pass"]?.execute?(commandBuffer) + + XCTAssertEqual(extensionInstance.observedPipelineName, "Test Compute Pipeline") + } + + func testRenderExtensionUnregisterRemovesOwnedComputePipelines() { + let computeType: ComputePipelineType = "test.unregister.compute.pipeline" + let extensionInstance = TestComputeRenderExtension(computeType: computeType) + + setRendering(.extensions(.register(extensionInstance))) + XCTAssertNotNil(ComputePipelineManager.shared.pipeline(for: computeType)) + + setRendering(.extensions(.unregister(extensionInstance.id))) + XCTAssertNil(ComputePipelineManager.shared.pipeline(for: computeType), + "Unregistering an extension should remove its owned compute pipelines") + } + + func testRenderExtensionRegistersShaderLibrary() { + let libraryID: RenderShaderLibraryID = "test.shader.library" + let extensionInstance = TestShaderLibraryRenderExtension( + libraryID: libraryID, + library: renderInfo.library + ) + + setRendering(.extensions(.register(extensionInstance))) + + XCTAssertNotNil(RenderShaderLibraryManager.shared.library(libraryID), + "Render extension should register its shader library after renderer initialization") + } + + func testRenderExtensionUnregisterRemovesOwnedShaderLibraries() { + let libraryID: RenderShaderLibraryID = "test.unregister.shader.library" + let extensionInstance = TestShaderLibraryRenderExtension( + libraryID: libraryID, + library: renderInfo.library + ) + + setRendering(.extensions(.register(extensionInstance))) + XCTAssertNotNil(RenderShaderLibraryManager.shared.library(libraryID)) + + setRendering(.extensions(.unregister(extensionInstance.id))) + XCTAssertNil(RenderShaderLibraryManager.shared.library(libraryID), + "Unregistering an extension should remove its owned shader libraries") + } + + func testReplacingRenderExtensionRemovesStaleOwnedArtifactsAndPreservesOrder() throws { + let original = TestLifecycleRenderExtension( + artifactSuffix: "original", + shaderLibrary: renderInfo.library + ) + let replacement = TestLifecycleRenderExtension( + artifactSuffix: "replacement", + shaderLibrary: renderInfo.library + ) + let trailing = TestStageRenderExtension( + passID: "test.lifecycle.trailing.pass", + stage: .afterTransparency + ) + + setRendering(.extensions(.register(original))) + setRendering(.extensions(.register(trailing))) + assertLifecycleArtifactsPresent(original) + + setRendering(.extensions(.register(replacement))) + + XCTAssertEqual( + RenderExtensionRegistry.shared.registeredIDs(), + [replacement.id, trailing.id] + ) + assertLifecycleArtifactsAbsent(original) + assertLifecycleArtifactsPresent(replacement) + + let (graph, _) = try buildGameModeGraph() + XCTAssertNil(graph[original.passID]) + XCTAssertEqual(graph[replacement.passID]?.dependencies, ["transparency"]) + XCTAssertEqual(graph[trailing.passID]?.dependencies, [replacement.passID]) + } + + func testUnregisterRenderExtensionRemovesEveryOwnedArtifact() { + let renderExtension = TestLifecycleRenderExtension( + artifactSuffix: "unregister", + shaderLibrary: renderInfo.library + ) + + setRendering(.extensions(.register(renderExtension))) + assertLifecycleArtifactsPresent(renderExtension) + + setRendering(.extensions(.unregister(renderExtension.id))) + + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + assertLifecycleArtifactsAbsent(renderExtension) + } + + func testUnregisterUnknownExtensionLeavesRegisteredArtifactsUntouched() { + let renderExtension = TestLifecycleRenderExtension( + artifactSuffix: "unknown.unregister", + shaderLibrary: renderInfo.library + ) + setRendering(.extensions(.register(renderExtension))) + + setRendering(.extensions(.unregister("test.lifecycle.unknown"))) + + XCTAssertTrue(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + assertLifecycleArtifactsPresent(renderExtension) + } + + func testArtifactCollisionsRejectSecondProviderWithoutOverwritingFirst() { + let first = TestLifecycleRenderExtension( + id: "test.collision.first", + artifactSuffix: "shared", + shaderLibrary: renderInfo.library + ) + let second = TestLifecycleRenderExtension( + id: "test.collision.second", + artifactSuffix: "shared", + shaderLibrary: renderInfo.library + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(first), .registered) + let result = RenderExtensionRegistry.shared.register(second) + + guard case let .rejected(conflicts) = result else { + XCTFail("Expected the second provider to be rejected") + return + } + XCTAssertEqual( + Set(conflicts.map(\.kind)), + Set([ + .shaderLibrary, + .renderPipeline, + .computePipeline, + .texture, + .buffer, + .argumentBuffer, + ]) + ) + XCTAssertTrue(conflicts.allSatisfy { $0.requestedOwnerID == second.id }) + XCTAssertTrue(conflicts.allSatisfy { $0.existingOwnerID == first.id }) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [first.id]) + XCTAssertEqual( + RenderExtensionRegistry.shared.registrationConflicts(forExtensionID: second.id), + conflicts + ) + assertLifecycleArtifactsPresent(first) + } + + func testExtensionCannotOverwriteBuiltInRenderPipeline() { + let builtInPipeline = PipelineManager.shared.renderPipelinesByType[.model] + let renderExtension = TestLifecycleRenderExtension( + id: "test.collision.builtin", + artifactSuffix: "builtin", + renderPipelineType: .model, + shaderLibrary: renderInfo.library + ) + + let result = RenderExtensionRegistry.shared.register(renderExtension) + + XCTAssertEqual( + result, + .rejected([ + RenderExtensionArtifactConflict( + kind: .renderPipeline, + artifactID: RenderPipelineType.model.rawValue, + requestedOwnerID: renderExtension.id, + existingOwnerID: nil + ), + ]) + ) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertEqual( + PipelineManager.shared.renderPipelinesByType[.model]?.name, + builtInPipeline?.name + ) + XCTAssertNil(getRenderResource(.texture(renderExtension.textureID))) + XCTAssertNil(getRenderResource(renderExtension.bufferID)) + XCTAssertNil(RenderShaderLibraryManager.shared.library(renderExtension.shaderLibraryID)) + XCTAssertNil(ComputePipelineManager.shared.pipeline(for: renderExtension.computePipelineType)) + XCTAssertNil( + RenderExtensionArgumentBufferRegistry.shared.descriptor(renderExtension.argumentLayoutID) + ) + } + + func testRejectedReplacementRestoresPreviousExtension() throws { + let original = TestLifecycleRenderExtension( + id: "test.collision.replacement", + artifactSuffix: "replacement.original", + shaderLibrary: renderInfo.library + ) + let blocker = TestLifecycleRenderExtension( + id: "test.collision.blocker", + artifactSuffix: "replacement.blocked", + shaderLibrary: renderInfo.library + ) + let rejectedReplacement = TestLifecycleRenderExtension( + id: original.id, + artifactSuffix: "replacement.blocked", + shaderLibrary: renderInfo.library + ) + XCTAssertEqual(RenderExtensionRegistry.shared.register(original), .registered) + XCTAssertEqual(RenderExtensionRegistry.shared.register(blocker), .registered) + + let result = RenderExtensionRegistry.shared.register(rejectedReplacement) + + guard case .rejected = result else { + XCTFail("Expected replacement registration to be rejected") + return + } + XCTAssertEqual( + RenderExtensionRegistry.shared.registeredIDs(), + [original.id, blocker.id] + ) + assertLifecycleArtifactsPresent(original) + assertLifecycleArtifactsPresent(blocker) + + let (graph, _) = try buildGameModeGraph() + XCTAssertNotNil(graph[original.passID]) + XCTAssertNotNil(graph[blocker.passID]) + } + + func testDeferredPipelineCollisionRemovesRejectedExtensionBeforeGraphBuild() throws { + let pipelineType: RenderPipelineType = "test.collision.deferred.pipeline" + let first = TestRenderPipelineOnlyExtension( + id: "test.collision.deferred.first", + pipelineType: pipelineType, + passID: "test.collision.deferred.first.pass" + ) + let second = TestRenderPipelineOnlyExtension( + id: "test.collision.deferred.second", + pipelineType: pipelineType, + passID: "test.collision.deferred.second.pass" + ) + let device = renderInfo.device + let library = renderInfo.library + + renderInfo.device = nil + renderInfo.library = nil + XCTAssertEqual(RenderExtensionRegistry.shared.register(first), .registered) + XCTAssertEqual(RenderExtensionRegistry.shared.register(second), .registered) + renderInfo.device = device + renderInfo.library = library + + RenderExtensionRegistry.shared.registerPipelines() + + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [first.id]) + XCTAssertEqual( + PipelineManager.shared.renderPipelinesByType[pipelineType]?.name, + first.id + ) + XCTAssertEqual( + RenderExtensionRegistry.shared.registrationConflicts(forExtensionID: second.id), + [ + RenderExtensionArtifactConflict( + kind: .renderPipeline, + artifactID: pipelineType.rawValue, + requestedOwnerID: second.id, + existingOwnerID: first.id + ), + ] + ) + + let (graph, _) = try buildGameModeGraph() + XCTAssertNotNil(graph[first.passID]) + XCTAssertNil(graph[second.passID]) + } + + func testUnownedRenderPipelineUpdateSurvivesExtensionUnregister() { + let renderExtension = TestLifecycleRenderExtension( + artifactSuffix: "pipeline.override", + shaderLibrary: renderInfo.library + ) + setRendering(.extensions(.register(renderExtension))) + + PipelineManager.shared.update( + rendererPipeLine: RenderPipeline(success: true, name: "Engine Replacement"), + forType: renderExtension.renderPipelineType + ) + setRendering(.extensions(.unregister(renderExtension.id))) + + XCTAssertEqual( + PipelineManager.shared.renderPipelinesByType[renderExtension.renderPipelineType]?.name, + "Engine Replacement" + ) + } + + func testRemovingAllExtensionsPreservesBuiltInRenderPipelines() { + let builtInModelPipeline = PipelineManager.shared.renderPipelinesByType[.model] + XCTAssertNotNil(builtInModelPipeline) + + setRendering(.extensions(.register( + TestLifecycleRenderExtension( + artifactSuffix: "remove.all", + shaderLibrary: renderInfo.library + ) + ))) + setRendering(.extensions(.removeAll)) + + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[.model]) + } + + func testNestedRenderPipelineRegistrationRestoresOuterOwnerScope() { + let outerOwnerID = "test.lifecycle.outer.owner" + let innerOwnerID = "test.lifecycle.inner.owner" + let outerFirstType: RenderPipelineType = "test.lifecycle.outer.first" + let outerSecondType: RenderPipelineType = "test.lifecycle.outer.second" + let innerType: RenderPipelineType = "test.lifecycle.inner" + + PipelineManager.shared.registerPipelines(ownerID: outerOwnerID) { outerRegistry in + outerRegistry.registerRenderPipeline(outerFirstType) { + RenderPipeline(success: true, name: "Outer First") + } + PipelineManager.shared.registerPipelines(ownerID: innerOwnerID) { innerRegistry in + innerRegistry.registerRenderPipeline(innerType) { + RenderPipeline(success: true, name: "Inner") + } + } + outerRegistry.registerRenderPipeline(outerSecondType) { + RenderPipeline(success: true, name: "Outer Second") + } + } + + PipelineManager.shared.removePipelines(ownerID: outerOwnerID) + + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[outerFirstType]) + XCTAssertNil(PipelineManager.shared.renderPipelinesByType[outerSecondType]) + XCTAssertNotNil(PipelineManager.shared.renderPipelinesByType[innerType]) + + PipelineManager.shared.removePipelines(ownerID: innerOwnerID) + } + + func testRenderExtensionRenderPipelineUsesRegisteredShaderLibrary() { + let pipelineType: RenderPipelineType = "test.registered.shader.render.pipeline" + let extensionInstance = TestRegisteredShaderPipelineRenderExtension( + libraryID: "test.registered.render.shader.library", + renderPipelineType: pipelineType, + computePipelineType: "test.unused.compute.pipeline", + library: renderInfo.library + ) + + setRendering(.extensions(.register(extensionInstance))) + + let pipeline = PipelineManager.shared.renderPipelinesByType[pipelineType] + XCTAssertNotNil(pipeline) + XCTAssertTrue(pipeline?.success == true) + XCTAssertEqual(pipeline?.name, "Test Registered Shader Render Pipeline") + } + + func testRenderExtensionComputePipelineUsesRegisteredShaderLibrary() { + let computeType: ComputePipelineType = "test.registered.shader.compute.pipeline" + let extensionInstance = TestRegisteredShaderPipelineRenderExtension( + libraryID: "test.registered.compute.shader.library", + renderPipelineType: "test.unused.render.pipeline", + computePipelineType: computeType, + library: renderInfo.library + ) + + setRendering(.extensions(.register(extensionInstance))) + + let pipeline = ComputePipelineManager.shared.pipeline(for: computeType) + XCTAssertNotNil(pipeline) + XCTAssertTrue(pipeline?.success == true) + XCTAssertEqual(pipeline?.name, "Test Registered Shader Compute Pipeline") + } + + private func assertLifecycleArtifactsPresent( + _ renderExtension: TestLifecycleRenderExtension, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertNotNil( + getRenderResource(.texture(renderExtension.textureID)), + file: file, + line: line + ) + XCTAssertNotNil( + getRenderResource(renderExtension.bufferID), + file: file, + line: line + ) + XCTAssertNotNil( + RenderShaderLibraryManager.shared.library(renderExtension.shaderLibraryID), + file: file, + line: line + ) + XCTAssertNotNil( + PipelineManager.shared.renderPipelinesByType[renderExtension.renderPipelineType], + file: file, + line: line + ) + XCTAssertNotNil( + ComputePipelineManager.shared.pipeline(for: renderExtension.computePipelineType), + file: file, + line: line + ) + XCTAssertNotNil( + RenderExtensionArgumentBufferRegistry.shared.descriptor(renderExtension.argumentLayoutID), + file: file, + line: line + ) + } + + private func assertLifecycleArtifactsAbsent( + _ renderExtension: TestLifecycleRenderExtension, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertNil( + getRenderResource(.texture(renderExtension.textureID)), + file: file, + line: line + ) + XCTAssertNil( + getRenderResource(renderExtension.bufferID), + file: file, + line: line + ) + XCTAssertNil( + RenderShaderLibraryManager.shared.library(renderExtension.shaderLibraryID), + file: file, + line: line + ) + XCTAssertNil( + PipelineManager.shared.renderPipelinesByType[renderExtension.renderPipelineType], + file: file, + line: line + ) + XCTAssertNil( + ComputePipelineManager.shared.pipeline(for: renderExtension.computePipelineType), + file: file, + line: line + ) + XCTAssertNil( + RenderExtensionArgumentBufferRegistry.shared.descriptor(renderExtension.argumentLayoutID), + file: file, + line: line + ) + } + + func testRenderPassContextDrawsModelSurfaceEntities() throws { + let entity = createEntity() + setEntityMeshDirect( + entityId: entity, + meshes: BasicPrimitives.createCube(extent: 1.0), + assetName: "test_model_surface_cube" + ) + visibleEntityIds = [entity] + + let extensionInstance = TestModelSurfaceDrawRenderExtension( + pipelineType: "test.model.surface.pipeline" + ) + setRendering(.extensions(.register(extensionInstance))) + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph[extensionInstance.passID]?.execute?(commandBuffer) + + XCTAssertEqual(extensionInstance.boundEntityIDs, [entity]) + } + + func testRenderPassContextDrawsModelSurfaceEntitiesWithArgumentBufferBinding() throws { + let entity = createEntity() + setEntityMeshDirect( + entityId: entity, + meshes: BasicPrimitives.createCube(extent: 1.0), + assetName: "test_model_surface_argument_cube" + ) + visibleEntityIds = [entity] + + let extensionInstance = TestModelSurfaceArgumentDrawRenderExtension( + pipelineType: "test.model.surface.argument.pipeline" + ) + setRendering(.extensions(.register(extensionInstance))) + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph[extensionInstance.passID]?.execute?(commandBuffer) + + XCTAssertEqual(extensionInstance.boundEntityIDs, [entity]) + XCTAssertEqual(extensionInstance.encodedValues, [1.0]) + } + + func testRenderPassContextMakesArgumentBufferResourcesResident() throws { + let shaderSource = """ + #include + using namespace metal; + + struct TestArguments { + texture2d texture0 [[id(0)]]; + texture2d texture1 [[id(1)]]; + texture2d texture2 [[id(2)]]; + texture2d texture3 [[id(3)]]; + texture2d texture4 [[id(4)]]; + texture2d texture5 [[id(5)]]; + texture2d texture6 [[id(6)]]; + texture2d texture7 [[id(7)]]; + sampler sampler0 [[id(8)]]; + sampler sampler1 [[id(9)]]; + sampler sampler2 [[id(10)]]; + sampler sampler3 [[id(11)]]; + sampler sampler4 [[id(12)]]; + sampler sampler5 [[id(13)]]; + sampler sampler6 [[id(14)]]; + sampler sampler7 [[id(15)]]; + constant uchar *buffer0 [[id(16)]]; + constant uchar *buffer1 [[id(17)]]; + constant uchar *buffer2 [[id(18)]]; + constant uchar *buffer3 [[id(19)]]; + constant uchar *buffer4 [[id(20)]]; + constant uchar *buffer5 [[id(21)]]; + constant uchar *buffer6 [[id(22)]]; + constant uchar *buffer7 [[id(23)]]; + constant uchar *buffer8 [[id(24)]]; + constant uchar *buffer9 [[id(25)]]; + constant uchar *buffer10 [[id(26)]]; + constant uchar *buffer11 [[id(27)]]; + constant uchar *buffer12 [[id(28)]]; + constant uchar *buffer13 [[id(29)]]; + constant uchar *buffer14 [[id(30)]]; + constant uchar *buffer15 [[id(31)]]; + }; + + fragment float4 testArgumentBufferFragment( + constant TestArguments &arguments [[buffer(10)]]) + { + return *reinterpret_cast(arguments.buffer0); + } + """ + let fragmentLibrary = try renderInfo.device.makeLibrary(source: shaderSource, options: nil) + let entity = createEntity() + setEntityMeshDirect( + entityId: entity, + meshes: BasicPrimitives.createPlane(), + assetName: "test_argument_buffer_residency_plane" + ) + visibleEntityIds = [entity] + + let extensionInstance = TestModelSurfaceArgumentDrawRenderExtension( + pipelineType: "test.argument.buffer.residency.pipeline", + argumentLayoutID: "test.argument.buffer.residency.layout", + fragmentShader: "testArgumentBufferFragment", + fragmentLibraryID: "test.argument.buffer.residency.shaders", + fragmentLibrary: fragmentLibrary + ) + setRendering(.extensions(.register(extensionInstance))) + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph[extensionInstance.passID]?.execute?(commandBuffer) + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + + XCTAssertEqual(commandBuffer.status, .completed) + XCTAssertNil(commandBuffer.error) + } + + func testRenderExtensionsOwnIndependentArgumentLayoutsWithSharedLocalIDs() throws { + let entity = createEntity() + setEntityMeshDirect( + entityId: entity, + meshes: BasicPrimitives.createCube(extent: 1.0), + assetName: "test_model_surface_argument_layout_cube" + ) + visibleEntityIds = [entity] + + let waterLayoutID = "test.water.argument.layout" + let grassLayoutID = "test.grass.argument.layout" + let waterExtension = TestModelSurfaceArgumentDrawRenderExtension( + id: "test.water.argument.extension", + pipelineType: "test.water.argument.pipeline", + passID: "test.water.argument.pass", + argumentLayoutID: waterLayoutID + ) + let grassExtension = TestModelSurfaceArgumentDrawRenderExtension( + id: "test.grass.argument.extension", + pipelineType: "test.grass.argument.pipeline", + passID: "test.grass.argument.pass", + argumentLayoutID: grassLayoutID + ) + + setRendering(.extensions(.register(waterExtension))) + setRendering(.extensions(.register(grassExtension))) + antiAliasingMode = .none + defer { antiAliasingMode = .fxaa } + + XCTAssertNotNil(RenderExtensionArgumentBufferRegistry.shared.descriptor(waterLayoutID)) + XCTAssertNotNil(RenderExtensionArgumentBufferRegistry.shared.descriptor(grassLayoutID)) + XCTAssertEqual( + RenderExtensionArgumentBufferRegistry.shared.descriptor(waterLayoutID)?.buffers.first?.id, + RenderExtensionModelSurfaceArgument.buffer0 + ) + XCTAssertEqual( + RenderExtensionArgumentBufferRegistry.shared.descriptor(grassLayoutID)?.buffers.first?.id, + RenderExtensionModelSurfaceArgument.buffer0 + ) + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph[waterExtension.passID]?.execute?(commandBuffer) + graph[grassExtension.passID]?.execute?(commandBuffer) + + XCTAssertEqual(waterExtension.boundEntityIDs, [entity]) + XCTAssertEqual(grassExtension.boundEntityIDs, [entity]) + XCTAssertEqual(waterExtension.encodedValues, [1.0]) + XCTAssertEqual(grassExtension.encodedValues, [1.0]) + + setRendering(.extensions(.unregister(waterExtension.id))) + + XCTAssertNil(RenderExtensionArgumentBufferRegistry.shared.descriptor(waterLayoutID)) + XCTAssertNotNil(RenderExtensionArgumentBufferRegistry.shared.descriptor(grassLayoutID)) + } + + func testModelSurfaceExtensionPipelineValidationAcceptsArgumentBufferSlot() { + let isValid = validateModelSurfaceExtensionPipelineArguments( + [ + RenderExtensionShaderArgument( + name: "arguments", + index: RenderExtensionModelSurfaceArgument.argumentBufferIndex, + type: .buffer + ), + ], + argumentLayoutID: nil, + pipelineName: "Test Valid Model Surface Pipeline", + fragmentShader: "validFragment" + ) + + XCTAssertTrue(isValid) + } + + func testModelSurfaceExtensionPipelineValidationRejectsMissingArgumentBuffer() { + let isValid = validateModelSurfaceExtensionPipelineArguments( + [], + argumentLayoutID: nil, + pipelineName: "Test Missing Argument Buffer Pipeline", + fragmentShader: "missingArgumentFragment" + ) + + XCTAssertFalse(isValid) + } + + func testModelSurfaceExtensionPipelineValidationRejectsLegacyRawSlots() { + let isValid = validateModelSurfaceExtensionPipelineArguments( + [ + RenderExtensionShaderArgument( + name: "arguments", + index: RenderExtensionModelSurfaceArgument.argumentBufferIndex, + type: .buffer + ), + RenderExtensionShaderArgument( + name: "legacyTexture", + index: 10, + type: .texture + ), + RenderExtensionShaderArgument( + name: "legacyBuffer", + index: 11, + type: .buffer + ), + ], + argumentLayoutID: nil, + pipelineName: "Test Legacy Raw Slot Pipeline", + fragmentShader: "legacyRawSlotFragment" + ) + + XCTAssertFalse(isValid) + } + + func testModelSurfaceExtensionPipelineValidationRejectsMissingLayout() { + let missingLayoutID = "test.missing.argument.layout" + RenderExtensionArgumentBufferRegistry.shared.removeAll() + + let isValid = validateModelSurfaceExtensionPipelineArguments( + [ + RenderExtensionShaderArgument( + name: "arguments", + index: RenderExtensionModelSurfaceArgument.argumentBufferIndex, + type: .buffer + ), + ], + argumentLayoutID: missingLayoutID, + pipelineName: "Test Missing Layout Pipeline", + fragmentShader: "missingLayoutFragment" + ) + + XCTAssertFalse(isValid) + } + + func testSampleRenderExtensionRegistersScratchTextureAndGraphPass() throws { + let sample = SampleRenderExtension() + + setRendering(.extensions(.register(sample))) + + let texture = getRenderResource(.texture(sample.scratchTextureID)) + XCTAssertNotNil(texture) + XCTAssertEqual(texture?.width, windowWidth) + XCTAssertEqual(texture?.height, windowHeight) + + let (graph, _) = try buildGameModeGraph() + XCTAssertNotNil(graph[sample.passID]) + XCTAssertEqual(graph["outputTransform"]?.dependencies, [sample.passID]) + + let sorted = try topologicalSortGraph(graph: graph) + let order = sorted.map(\.id) + assertTopologicalConstraints(order: order, constraints: [ + ("look", sample.passID), + (sample.passID, "outputTransform"), + ]) + } + + func testSampleRenderExtensionPassExecutes() throws { + let sample = SampleRenderExtension() + setRendering(.extensions(.register(sample))) + + let (graph, _) = try buildGameModeGraph() + guard let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() else { + XCTFail("Expected command buffer") + return + } + + graph[sample.passID]?.execute?(commandBuffer) + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + + XCTAssertEqual(commandBuffer.status, .completed) + } + // MARK: - Helper Methods func assertTopologicalConstraints( diff --git a/Tests/UntoldEngineRenderTests/RenderGraphOptimizationReportTest.swift b/Tests/UntoldEngineRenderTests/RenderGraphOptimizationReportTest.swift new file mode 100644 index 00000000..299d734b --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderGraphOptimizationReportTest.swift @@ -0,0 +1,321 @@ +// +// RenderGraphOptimizationReportTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +final class RenderGraphOptimizationReportTest: XCTestCase { + override func setUp() { + super.setUp() + RenderResourceRegistry.shared.removeAll() + } + + override func tearDown() { + RenderResourceRegistry.shared.removeAll() + super.tearDown() + } + + func testReportCapturesGraphAndResourceStatistics() throws { + let first = registerBuffer("test.audit.first", lifetime: .transient) + let persistent = registerBuffer("test.audit.persistent", lifetime: .persistent) + let second = registerBuffer("test.audit.second", lifetime: .transient) + let compiled = try compileRenderGraph([ + "first": pass("first", uses: [.buffer(first, access: .write)]), + "persistent": pass( + "persistent", + dependencies: ["first"], + uses: [.buffer(persistent, access: .write)] + ), + "second": pass( + "second", + dependencies: ["persistent"], + uses: [.buffer(second, access: .write)] + ), + ]) + + XCTAssertEqual( + compiled.optimizationReport.statistics, + CompiledRenderGraphStatistics( + passCount: 3, + dependencyCount: 2, + explicitDependencyCount: 2, + inferredDependencyCount: 0, + usedResourceCount: 3, + persistentResourceCount: 1, + transientResourceCount: 2, + transientAliasSlotCount: 1, + aliasedResourceCount: 2, + backingStoreReductionOpportunityCount: 1, + unusedDeclaredResourceCount: 0, + redundantDependencyCount: 0 + ) + ) + XCTAssertTrue(compiled.optimizationReport.isResourcePlanValid) + } + + func testReportFindsRedundantExplicitDependency() throws { + let compiled = try compileRenderGraph([ + "a": pass("a"), + "b": pass("b", dependencies: ["a"]), + "c": pass("c", dependencies: ["a", "b"]), + ]) + + XCTAssertEqual( + compiled.optimizationReport.redundantDependencies, + [ + CompiledRenderGraphRedundantDependency( + passID: "c", + dependencyID: "a", + inferred: false + ), + ] + ) + } + + func testReportFindsRedundantInferredHazardDependency() throws { + let resourceID = registerBuffer("test.audit.inferred", lifetime: .persistent) + let compiled = try compileRenderGraph([ + "writer": pass("writer", uses: [.buffer(resourceID, access: .write)]), + "middle": pass("middle", dependencies: ["writer"]), + "reader": pass( + "reader", + dependencies: ["middle"], + uses: [.buffer(resourceID, access: .read)] + ), + ]) + + XCTAssertEqual( + compiled.optimizationReport.redundantDependencies, + [ + CompiledRenderGraphRedundantDependency( + passID: "reader", + dependencyID: "writer", + inferred: true + ), + ] + ) + } + + func testReportListsUnusedDeclarationsWithoutRemovingThem() throws { + let used = registerBuffer("test.audit.used", lifetime: .persistent) + let unused = registerBuffer("test.audit.unused", lifetime: .transient) + let compiled = try compileRenderGraph([ + "pass": pass("pass", uses: [.buffer(used, access: .write)]), + ]) + + XCTAssertEqual( + compiled.optimizationReport.unusedResources, + [ + RenderGraphResourceDeclarationSnapshot( + kind: .buffer, + resourceID: unused.rawValue, + ownerID: nil, + lifetime: .transient + ), + ] + ) + XCTAssertNotNil(RenderResourceRegistry.shared.bufferDeclaration(unused)) + } + + func testValidationRejectsOverlappingAliasPlan() throws { + let firstID = registerBuffer("test.audit.overlap.first", lifetime: .transient) + let secondID = registerBuffer("test.audit.overlap.second", lifetime: .transient) + let compiled = try compileRenderGraph([ + "first": pass("first", uses: [.buffer(firstID, access: .write)]), + "second": pass( + "second", + dependencies: ["first"], + uses: [.buffer(secondID, access: .write)] + ), + "read": pass( + "read", + dependencies: ["second"], + uses: [ + .buffer(firstID, access: .read), + .buffer(secondID, access: .read), + ] + ), + ]) + let resources = compiled.resourcePlan.resources.map { resource in + CompiledRenderGraphResource( + kind: resource.kind, + resourceID: resource.resourceID, + ownerID: resource.ownerID, + lifetime: resource.lifetime, + firstUsePassIndex: resource.firstUsePassIndex, + lastUsePassIndex: resource.lastUsePassIndex, + passIDs: resource.passIDs, + aliasSlotID: 0 + ) + } + let invalidPlan = CompiledRenderGraphResourcePlan( + resources: resources, + transientAliasSlots: [ + CompiledRenderGraphAliasSlot( + id: 0, + kind: .buffer, + ownerID: nil, + resourceIDs: [firstID.rawValue, secondID.rawValue] + ), + ] + ) + + XCTAssertEqual( + validateCompiledRenderGraphResourcePlan( + compiled.orderedPasses, + resourcePlan: invalidPlan + ), + [ + .overlappingAliasSlot( + slotID: 0, + firstResourceID: firstID.rawValue, + secondResourceID: secondID.rawValue + ), + ] + ) + } + + func testOptimizationReportIsDeterministic() throws { + let resourceID = registerBuffer("test.audit.deterministic", lifetime: .persistent) + let writer = pass("writer", uses: [.buffer(resourceID, access: .write)]) + let reader = pass( + "reader", + dependencies: ["writer"], + uses: [.buffer(resourceID, access: .read)] + ) + var forward: [String: RenderPass] = [:] + var reverse: [String: RenderPass] = [:] + forward[writer.id] = writer + forward[reader.id] = reader + reverse[reader.id] = reader + reverse[writer.id] = writer + + XCTAssertEqual( + try compileRenderGraph(forward).optimizationReport, + try compileRenderGraph(reverse).optimizationReport + ) + } + + func testOptimizationAuditDoesNotRemovePasses() throws { + let graph = [ + "root": pass("root"), + "side-effect": pass("side-effect"), + ] + + let compiled = try compileRenderGraph(graph) + + XCTAssertEqual(compiled.executionOrder, ["root", "side-effect"]) + XCTAssertEqual(compiled.optimizationReport.statistics.passCount, graph.count) + } + + private func registerBuffer( + _ id: String, + lifetime: RenderExtensionResourceLifetime + ) -> RenderBufferResourceID { + let resourceID = RenderBufferResourceID(id) + RenderResourceRegistry.shared.registerBuffer( + RenderExtensionBufferDescriptor( + id: resourceID, + length: 64, + lifetime: lifetime + ) + ) + return resourceID + } + + private func pass( + _ id: String, + dependencies: [String] = [], + uses resources: [RenderGraphResourceUsage] = [] + ) -> RenderPass { + RenderPass( + id: id, + dependencies: dependencies, + execute: nil, + resourceUsages: resources + ) + } +} + +final class RenderGraphOptimizationExtensionTest: BaseRenderSetup { + func testAuditCoversMultipleExtensionContributions() throws { + let water = OptimizationAuditRenderExtension(id: "test.audit.water") + let grass = OptimizationAuditRenderExtension(id: "test.audit.grass") + setRendering(.extensions(.register(water))) + setRendering(.extensions(.register(grass))) + + let compiled = try buildExecutableGameModeGraph() + let report = compiled.optimizationReport + + XCTAssertTrue(report.isResourcePlanValid) + XCTAssertEqual( + Set(report.unusedResources.map(\.resourceID)), + Set([water.unusedBufferID.rawValue, grass.unusedBufferID.rawValue]) + ) + let waterResource = try XCTUnwrap( + compiled.resourcePlan.resource( + kind: .buffer, + id: water.usedBufferID.rawValue + ) + ) + let grassResource = try XCTUnwrap( + compiled.resourcePlan.resource( + kind: .buffer, + id: grass.usedBufferID.rawValue + ) + ) + XCTAssertNotEqual(waterResource.aliasSlotID, grassResource.aliasSlotID) + XCTAssertEqual( + waterResource.ownerID, + water.id + ) + XCTAssertEqual( + grassResource.ownerID, + grass.id + ) + } +} + +private final class OptimizationAuditRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let usedBufferID: RenderBufferResourceID + let unusedBufferID: RenderBufferResourceID + + init(id: String) { + self.id = id + usedBufferID = RenderBufferResourceID("\(id).used") + unusedBufferID = RenderBufferResourceID("\(id).unused") + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerBuffer( + RenderExtensionBufferDescriptor( + id: usedBufferID, + length: 64, + lifetime: .transient + ) + ) + registry.registerBuffer( + RenderExtensionBufferDescriptor(id: unusedBufferID, length: 64) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: "\(id).pass", + stage: .beforeOutput, + resources: [.buffer(usedBufferID, access: .write)], + execute: nil + ) + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderGraphResourcePlanTest.swift b/Tests/UntoldEngineRenderTests/RenderGraphResourcePlanTest.swift new file mode 100644 index 00000000..02a3ae88 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderGraphResourcePlanTest.swift @@ -0,0 +1,243 @@ +// +// RenderGraphResourcePlanTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +final class RenderGraphResourcePlanTest: XCTestCase { + override func setUp() { + super.setUp() + RenderResourceRegistry.shared.removeAll() + } + + override func tearDown() { + RenderResourceRegistry.shared.removeAll() + super.tearDown() + } + + func testCompilationCapturesFirstAndLastResourceUse() throws { + let resourceID = registerBuffer("test.plan.interval", lifetime: .transient) + let compiled = try compileRenderGraph([ + "write": pass("write", uses: [.buffer(resourceID, access: .write)]), + "middle": pass("middle", dependencies: ["write"]), + "read": pass( + "read", + dependencies: ["middle"], + uses: [.buffer(resourceID, access: .read)] + ), + ]) + + let resource = try XCTUnwrap( + compiled.resourcePlan.resource(kind: .buffer, id: resourceID.rawValue) + ) + XCTAssertEqual(compiled.executionOrder, ["write", "middle", "read"]) + XCTAssertEqual(resource.firstUsePassIndex, 0) + XCTAssertEqual(resource.lastUsePassIndex, 2) + XCTAssertEqual(resource.passIDs, ["write", "read"]) + XCTAssertEqual(resource.lifetime, .transient) + } + + func testCompatibleNonOverlappingTransientResourcesShareAliasSlot() throws { + let firstID = registerBuffer("test.plan.alias.first", lifetime: .transient) + let secondID = registerBuffer("test.plan.alias.second", lifetime: .transient) + let compiled = try compileRenderGraph([ + "a-write": pass("a-write", uses: [.buffer(firstID, access: .write)]), + "b-read": pass( + "b-read", + dependencies: ["a-write"], + uses: [.buffer(firstID, access: .read)] + ), + "c-write": pass( + "c-write", + dependencies: ["b-read"], + uses: [.buffer(secondID, access: .write)] + ), + "d-read": pass( + "d-read", + dependencies: ["c-write"], + uses: [.buffer(secondID, access: .read)] + ), + ]) + + let first = try XCTUnwrap( + compiled.resourcePlan.resource(kind: .buffer, id: firstID.rawValue) + ) + let second = try XCTUnwrap( + compiled.resourcePlan.resource(kind: .buffer, id: secondID.rawValue) + ) + XCTAssertEqual(first.aliasSlotID, second.aliasSlotID) + XCTAssertEqual(compiled.resourcePlan.transientAliasSlots.count, 1) + XCTAssertEqual( + compiled.resourcePlan.transientAliasSlots[0].resourceIDs, + [firstID.rawValue, secondID.rawValue] + ) + } + + func testOverlappingTransientResourcesUseDifferentAliasSlots() throws { + let firstID = registerBuffer("test.plan.overlap.first", lifetime: .transient) + let secondID = registerBuffer("test.plan.overlap.second", lifetime: .transient) + let compiled = try compileRenderGraph([ + "a-first": pass("a-first", uses: [.buffer(firstID, access: .write)]), + "b-second": pass( + "b-second", + dependencies: ["a-first"], + uses: [.buffer(secondID, access: .write)] + ), + "c-read": pass( + "c-read", + dependencies: ["b-second"], + uses: [ + .buffer(firstID, access: .read), + .buffer(secondID, access: .read), + ] + ), + ]) + + let first = compiled.resourcePlan.resource(kind: .buffer, id: firstID.rawValue) + let second = compiled.resourcePlan.resource(kind: .buffer, id: secondID.rawValue) + XCTAssertNotEqual(first?.aliasSlotID, second?.aliasSlotID) + XCTAssertEqual(compiled.resourcePlan.transientAliasSlots.count, 2) + } + + func testPersistentResourcesAreExcludedFromAliasSlots() throws { + let resourceID = registerBuffer("test.plan.persistent", lifetime: .persistent) + let compiled = try compileRenderGraph([ + "write": pass("write", uses: [.buffer(resourceID, access: .write)]), + ]) + + let resource = try XCTUnwrap( + compiled.resourcePlan.resource(kind: .buffer, id: resourceID.rawValue) + ) + XCTAssertEqual(resource.lifetime, .persistent) + XCTAssertNil(resource.aliasSlotID) + XCTAssertTrue(compiled.resourcePlan.transientAliasSlots.isEmpty) + } + + func testIncompatibleTransientResourcesUseDifferentAliasSlots() throws { + let firstID = registerBuffer( + "test.plan.incompatible.first", + length: 64, + lifetime: .transient + ) + let secondID = registerBuffer( + "test.plan.incompatible.second", + length: 128, + lifetime: .transient + ) + let compiled = try compileRenderGraph([ + "first": pass("first", uses: [.buffer(firstID, access: .write)]), + "second": pass( + "second", + dependencies: ["first"], + uses: [.buffer(secondID, access: .write)] + ), + ]) + + XCTAssertEqual(compiled.resourcePlan.transientAliasSlots.count, 2) + } + + func testTransientTexturesRequireExactDescriptorCompatibility() throws { + let firstID = registerTexture( + "test.plan.texture.first", + pixelFormat: .rgba8Unorm + ) + let secondID = registerTexture( + "test.plan.texture.second", + pixelFormat: .rgba16Float + ) + let compiled = try compileRenderGraph([ + "first": pass("first", uses: [.texture(firstID, access: .renderTarget)]), + "second": pass( + "second", + dependencies: ["first"], + uses: [.texture(secondID, access: .renderTarget)] + ), + ]) + + XCTAssertEqual(compiled.resourcePlan.transientAliasSlots.count, 2) + XCTAssertEqual( + compiled.resourcePlan.transientAliasSlots.map(\.kind), + [.texture, .texture] + ) + } + + func testResourcePlanIsDeterministicAndImmutable() throws { + let firstID = registerBuffer("test.plan.snapshot.first", lifetime: .transient) + let secondID = registerBuffer("test.plan.snapshot.second", lifetime: .transient) + let firstPass = pass("first", uses: [.buffer(firstID, access: .write)]) + let secondPass = pass( + "second", + dependencies: ["first"], + uses: [.buffer(secondID, access: .write)] + ) + var forward: [String: RenderPass] = [:] + var reverse: [String: RenderPass] = [:] + forward[firstPass.id] = firstPass + forward[secondPass.id] = secondPass + reverse[secondPass.id] = secondPass + reverse[firstPass.id] = firstPass + + let firstCompilation = try compileRenderGraph(forward) + let secondCompilation = try compileRenderGraph(reverse) + let capturedPlan = firstCompilation.resourcePlan + RenderResourceRegistry.shared.registerBuffer( + RenderExtensionBufferDescriptor(id: firstID, length: 256) + ) + + XCTAssertEqual(firstCompilation.resourcePlan, secondCompilation.resourcePlan) + XCTAssertEqual(firstCompilation.resourcePlan, capturedPlan) + } + + private func registerBuffer( + _ id: String, + length: Int = 64, + lifetime: RenderExtensionResourceLifetime + ) -> RenderBufferResourceID { + let resourceID = RenderBufferResourceID(id) + RenderResourceRegistry.shared.registerBuffer( + RenderExtensionBufferDescriptor( + id: resourceID, + length: length, + lifetime: lifetime + ) + ) + return resourceID + } + + private func registerTexture( + _ id: String, + pixelFormat: MTLPixelFormat + ) -> RenderTextureResourceID { + let resourceID = RenderTextureResourceID(id) + RenderResourceRegistry.shared.registerTexture( + RenderExtensionTextureDescriptor( + id: resourceID, + size: .fixed(width: 8, height: 8), + pixelFormat: pixelFormat, + usage: .renderTarget, + lifetime: .transient + ) + ) + return resourceID + } + + private func pass( + _ id: String, + dependencies: [String] = [], + uses resources: [RenderGraphResourceUsage] = [] + ) -> RenderPass { + RenderPass( + id: id, + dependencies: dependencies, + execute: nil, + resourceUsages: resources + ) + } +} diff --git a/Tests/UntoldEngineRenderTests/RenderGraphResourceSchedulingTest.swift b/Tests/UntoldEngineRenderTests/RenderGraphResourceSchedulingTest.swift new file mode 100644 index 00000000..122e8ab6 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderGraphResourceSchedulingTest.swift @@ -0,0 +1,295 @@ +// +// RenderGraphResourceSchedulingTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +final class RenderGraphResourceSchedulingTest: BaseRenderSetup { + func testCompilationInfersWriterBeforeLeadingReader() throws { + let bufferID = declareBuffer("test.schedule.raw") + let reader = RenderPass( + id: "a-reader", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .read)] + ) + let writer = RenderPass( + id: "z-writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + + let analysis = analyzeRenderGraph([reader.id: reader, writer.id: writer]) + let compiled = try XCTUnwrap(analysis.compiledGraph) + + XCTAssertTrue(analysis.validationReport.isValid) + XCTAssertEqual(compiled.executionOrder, [writer.id, reader.id]) + XCTAssertEqual(compiled.passesByID[reader.id]?.dependencies, [writer.id]) + XCTAssertEqual(compiled.passesByID[reader.id]?.inferredDependencies, [writer.id]) + XCTAssertTrue(compiled.passesByID[writer.id]?.inferredDependencies.isEmpty == true) + } + + func testCompilationInfersRAWAndWARDependenciesAcrossWrites() throws { + let bufferID = declareBuffer("test.schedule.raw-war") + let firstWriter = RenderPass( + id: "a-writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + let reader = RenderPass( + id: "b-reader", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .read)] + ) + let secondWriter = RenderPass( + id: "c-writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + + let compiled = try compileRenderGraph([ + firstWriter.id: firstWriter, + reader.id: reader, + secondWriter.id: secondWriter, + ]) + + XCTAssertEqual(compiled.executionOrder, [firstWriter.id, reader.id, secondWriter.id]) + XCTAssertEqual(compiled.passesByID[reader.id]?.inferredDependencies, [firstWriter.id]) + XCTAssertEqual( + compiled.passesByID[secondWriter.id]?.inferredDependencies, + [firstWriter.id, reader.id] + ) + } + + func testCompilationInfersWAWDependencyBetweenUnorderedWriters() throws { + let bufferID = declareBuffer("test.schedule.waw") + let first = RenderPass( + id: "first", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + let second = RenderPass( + id: "second", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + + let compiled = try compileRenderGraph([second.id: second, first.id: first]) + + XCTAssertEqual(compiled.executionOrder, [first.id, second.id]) + XCTAssertEqual(compiled.passesByID[second.id]?.inferredDependencies, [first.id]) + } + + func testRenderTargetAccessSchedulesBeforeShaderReader() throws { + let textureID: RenderTextureResourceID = "test.schedule.render-target" + RenderResourceRegistry.shared.registerTexture( + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 4, height: 4), + pixelFormat: .rgba8Unorm, + usage: [.renderTarget, .shaderRead] + ) + ) + let reader = RenderPass( + id: "reader", + dependencies: [], + execute: nil, + resourceUsages: [.texture(textureID, access: .read)] + ) + let renderTarget = RenderPass( + id: "render-target", + dependencies: [], + execute: nil, + resourceUsages: [.texture(textureID, access: .renderTarget)] + ) + + let compiled = try compileRenderGraph([ + reader.id: reader, + renderTarget.id: renderTarget, + ]) + + XCTAssertEqual(compiled.executionOrder, [renderTarget.id, reader.id]) + XCTAssertEqual( + compiled.passesByID[reader.id]?.inferredDependencies, + [renderTarget.id] + ) + } + + func testCompilationSchedulesIndependentHazardsForMultipleExtensions() throws { + let grass = SchedulingResourceRenderExtension(id: "com.example.grass") + let water = SchedulingResourceRenderExtension(id: "com.example.water") + setRendering(.extensions(.register(grass))) + setRendering(.extensions(.register(water))) + + let grassReader = RenderPass( + id: "a-grass-reader", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(grass.bufferID, access: .read)], + owner: .renderExtension(grass.id) + ) + let grassWriter = RenderPass( + id: "b-grass-writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(grass.bufferID, access: .write)], + owner: .renderExtension(grass.id) + ) + let waterReader = RenderPass( + id: "c-water-reader", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(water.bufferID, access: .read)], + owner: .renderExtension(water.id) + ) + let waterWriter = RenderPass( + id: "d-water-writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(water.bufferID, access: .write)], + owner: .renderExtension(water.id) + ) + + let compiled = try compileRenderGraph([ + grassReader.id: grassReader, + grassWriter.id: grassWriter, + waterReader.id: waterReader, + waterWriter.id: waterWriter, + ]) + + XCTAssertEqual( + compiled.passesByID[grassReader.id]?.inferredDependencies, + [grassWriter.id] + ) + XCTAssertEqual( + compiled.passesByID[waterReader.id]?.inferredDependencies, + [waterWriter.id] + ) + XCTAssertEqual(compiled.passesByID[grassReader.id]?.owner, .renderExtension(grass.id)) + XCTAssertEqual(compiled.passesByID[waterWriter.id]?.owner, .renderExtension(water.id)) + } + + func testExplicitResourceOrderingIsPreservedWithoutDuplicateInference() throws { + let bufferID = declareBuffer("test.schedule.explicit") + let writer = RenderPass( + id: "writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + let reader = RenderPass( + id: "reader", + dependencies: [writer.id], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .read)] + ) + + let compiled = try compileRenderGraph([reader.id: reader, writer.id: writer]) + + XCTAssertEqual(compiled.passesByID[reader.id]?.dependencies, [writer.id]) + XCTAssertTrue(compiled.passesByID[reader.id]?.inferredDependencies.isEmpty == true) + } + + func testContradictoryExplicitOrderingIsNotReversed() { + let bufferID = declareBuffer("test.schedule.conflict") + let reader = RenderPass( + id: "reader", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .read)] + ) + let writer = RenderPass( + id: "writer", + dependencies: [reader.id], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + + let analysis = analyzeRenderGraph([reader.id: reader, writer.id: writer]) + + XCTAssertNil(analysis.compiledGraph) + XCTAssertEqual(analysis.scheduledGraph[writer.id]?.dependencies, [reader.id]) + XCTAssertTrue(analysis.scheduledGraph[reader.id]?.inferredDependencies.isEmpty == true) + XCTAssertEqual( + analysis.validationReport.errors, + [ + .readBeforeWrite( + passID: reader.id, + kind: .buffer, + resourceID: bufferID.rawValue + ), + ] + ) + } + + func testSchedulingIsIndependentOfDictionaryInsertionOrder() throws { + let bufferID = declareBuffer("test.schedule.deterministic") + let reader = RenderPass( + id: "reader", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .read)] + ) + let writer = RenderPass( + id: "writer", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)] + ) + var forward: [String: RenderPass] = [:] + var reverse: [String: RenderPass] = [:] + forward[reader.id] = reader + forward[writer.id] = writer + reverse[writer.id] = writer + reverse[reader.id] = reader + + let first = try compileRenderGraph(forward) + let second = try compileRenderGraph(reverse) + + XCTAssertEqual(first.executionOrder, second.executionOrder) + XCTAssertEqual( + first.passesByID[reader.id]?.inferredDependencies, + second.passesByID[reader.id]?.inferredDependencies + ) + } + + private func declareBuffer(_ id: String) -> RenderBufferResourceID { + let bufferID = RenderBufferResourceID(id) + RenderResourceRegistry.shared.registerBuffer( + RenderExtensionBufferDescriptor(id: bufferID, length: 64) + ) + return bufferID + } +} + +private final class SchedulingResourceRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let bufferID: RenderBufferResourceID + + init(id: String) { + self.id = id + bufferID = RenderBufferResourceID("\(id).buffer") + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerBuffer(RenderExtensionBufferDescriptor(id: bufferID, length: 64)) + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} diff --git a/Tests/UntoldEngineRenderTests/RenderShaderLibraryPackagingTest.swift b/Tests/UntoldEngineRenderTests/RenderShaderLibraryPackagingTest.swift new file mode 100644 index 00000000..97e519af --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderShaderLibraryPackagingTest.swift @@ -0,0 +1,285 @@ +// +// RenderShaderLibraryPackagingTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +@testable import UntoldEngine +import XCTest + +private enum TestShaderLibraryLoaderError: Error { + case failed +} + +private final class TestRenderShaderLibraryLoader: RenderShaderLibraryLoading { + var resourceURLResult: URL? + var library: MTLLibrary? + var defaultLibraryShouldFail = false + var libraryShouldFail = false + private(set) var requestedResource: String? + private(set) var requestedSubdirectory: String? + private(set) var requestedURL: URL? + private(set) var requestedDefaultBundle: Bundle? + + func resourceURL( + in _: Bundle, + resource: String, + subdirectory: String? + ) -> URL? { + requestedResource = resource + requestedSubdirectory = subdirectory + return resourceURLResult + } + + func makeDefaultLibrary(device _: MTLDevice, bundle: Bundle) throws -> MTLLibrary { + requestedDefaultBundle = bundle + if defaultLibraryShouldFail { throw TestShaderLibraryLoaderError.failed } + guard let library else { throw TestShaderLibraryLoaderError.failed } + return library + } + + func makeLibrary(device _: MTLDevice, url: URL) throws -> MTLLibrary { + requestedURL = url + if libraryShouldFail { throw TestShaderLibraryLoaderError.failed } + guard let library else { throw TestShaderLibraryLoaderError.failed } + return library + } +} + +private final class TestPackagedShaderLibraryExtension: RenderExtension, @unchecked Sendable { + let id: String + let libraryID: RenderShaderLibraryID + let source: RenderShaderLibrarySource + let additionalLibraries: [(id: RenderShaderLibraryID, source: RenderShaderLibrarySource)] + + init( + id: String, + libraryID: RenderShaderLibraryID, + source: RenderShaderLibrarySource, + additionalLibraries: [(id: RenderShaderLibraryID, source: RenderShaderLibrarySource)] = [] + ) { + self.id = id + self.libraryID = libraryID + self.source = source + self.additionalLibraries = additionalLibraries + } + + func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerLibrary(libraryID, source: source) + for library in additionalLibraries { + registry.registerLibrary(library.id, source: library.source) + } + } + + func buildGraph( + _: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) {} +} + +final class RenderShaderLibraryPackagingTest: BaseRenderSetup { + func testBundledMetallibResolvesRelativeToProvidedBundle() { + let libraryID: RenderShaderLibraryID = "com.untold.water.shaders" + let expectedURL = URL(fileURLWithPath: "/virtual/Shaders/Water.metallib") + let loader = TestRenderShaderLibraryLoader() + loader.resourceURLResult = expectedURL + loader.library = renderInfo.library + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) } + let renderExtension = TestPackagedShaderLibraryExtension( + id: "com.untold.water", + libraryID: libraryID, + source: .metallib( + bundle: .main, + resource: "Water", + subdirectory: "Shaders" + ) + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + + XCTAssertTrue(RenderShaderLibraryManager.shared.library(libraryID) === renderInfo.library) + XCTAssertEqual(loader.requestedResource, "Water") + XCTAssertEqual(loader.requestedSubdirectory, "Shaders") + XCTAssertEqual(loader.requestedURL, expectedURL) + } + + func testMissingBundledMetallibRejectsExtensionWithStructuredError() { + let libraryID: RenderShaderLibraryID = "com.untold.missing.shaders" + let loader = TestRenderShaderLibraryLoader() + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) } + let renderExtension = TestPackagedShaderLibraryExtension( + id: "com.untold.missing", + libraryID: libraryID, + source: .metallib(bundle: .main, resource: "Missing", subdirectory: "Shaders") + ) + let expectedError = RenderShaderLibraryLoadingError.resourceNotFound( + libraryID: libraryID, + resource: "Missing", + subdirectory: "Shaders" + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension), + .rejectedArtifacts( + conflicts: [], + shaderLibraryErrors: [expectedError], + pipelineErrors: [], + resourceValidationErrors: [] + ) + ) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertNil(RenderShaderLibraryManager.shared.library(libraryID)) + XCTAssertEqual( + RenderExtensionRegistry.shared.shaderLibraryErrors(forExtensionID: renderExtension.id), + [expectedError] + ) + } + + func testInvalidBundledMetallibRejectsExtension() { + let libraryID: RenderShaderLibraryID = "com.untold.invalid.shaders" + let loader = TestRenderShaderLibraryLoader() + loader.resourceURLResult = URL(fileURLWithPath: "/virtual/Invalid.metallib") + loader.libraryShouldFail = true + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) } + let renderExtension = TestPackagedShaderLibraryExtension( + id: "com.untold.invalid", + libraryID: libraryID, + source: .metallib(bundle: .main, resource: "Invalid") + ) + + XCTAssertEqual( + RenderExtensionRegistry.shared.register(renderExtension).shaderLibraryErrors, + [ + .metallibCreationFailed( + libraryID: libraryID, + resource: "Invalid", + subdirectory: nil + ), + ] + ) + XCTAssertNil(RenderShaderLibraryManager.shared.library(libraryID)) + } + + func testOneFailedLibraryRollsBackAllLibrariesOwnedByExtension() { + let directLibraryID: RenderShaderLibraryID = "com.untold.partial.direct" + let missingLibraryID: RenderShaderLibraryID = "com.untold.partial.missing" + let loader = TestRenderShaderLibraryLoader() + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) } + let renderExtension = TestPackagedShaderLibraryExtension( + id: "com.untold.partial", + libraryID: directLibraryID, + source: .library(renderInfo.library), + additionalLibraries: [ + ( + id: missingLibraryID, + source: .metallib(bundle: .main, resource: "Missing") + ), + ] + ) + + guard case .rejectedArtifacts = RenderExtensionRegistry.shared.register(renderExtension) else { + XCTFail("Expected partial shader registration to be rejected") + return + } + + XCTAssertNil(RenderShaderLibraryManager.shared.library(directLibraryID)) + XCTAssertNil(RenderShaderLibraryManager.shared.library(missingLibraryID)) + } + + func testDefaultLibrarySourceUsesProvidedBundle() { + let libraryID: RenderShaderLibraryID = "com.untold.default.shaders" + let loader = TestRenderShaderLibraryLoader() + loader.library = renderInfo.library + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) } + let renderExtension = TestPackagedShaderLibraryExtension( + id: "com.untold.default", + libraryID: libraryID, + source: .defaultLibrary(bundle: .main) + ) + + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + XCTAssertTrue(loader.requestedDefaultBundle === Bundle.main) + XCTAssertTrue(RenderShaderLibraryManager.shared.library(libraryID) === renderInfo.library) + } + + func testFailedPackagedReplacementRestoresDirectLibraryExtension() { + let extensionID = "com.untold.replacement" + let originalLibraryID: RenderShaderLibraryID = "com.untold.replacement.original" + let replacementLibraryID: RenderShaderLibraryID = "com.untold.replacement.new" + let original = TestPackagedShaderLibraryExtension( + id: extensionID, + libraryID: originalLibraryID, + source: .library(renderInfo.library) + ) + XCTAssertEqual(RenderExtensionRegistry.shared.register(original), .registered) + + let loader = TestRenderShaderLibraryLoader() + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) } + let replacement = TestPackagedShaderLibraryExtension( + id: extensionID, + libraryID: replacementLibraryID, + source: .metallib(bundle: .main, resource: "Missing") + ) + + guard case .rejectedArtifacts = RenderExtensionRegistry.shared.register(replacement) else { + XCTFail("Expected packaged replacement to be rejected") + return + } + + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [extensionID]) + XCTAssertTrue( + RenderShaderLibraryManager.shared.library(originalLibraryID) === renderInfo.library + ) + XCTAssertNil(RenderShaderLibraryManager.shared.library(replacementLibraryID)) + } + + func testDeferredPackagedShaderFailureRemovesExtensionWhenMetalBecomesReady() { + let libraryID: RenderShaderLibraryID = "com.untold.deferred.shaders" + let renderExtension = TestPackagedShaderLibraryExtension( + id: "com.untold.deferred", + libraryID: libraryID, + source: .metallib(bundle: .main, resource: "Missing") + ) + let device = renderInfo.device + let library = renderInfo.library + renderInfo.device = nil + renderInfo.library = nil + XCTAssertEqual(RenderExtensionRegistry.shared.register(renderExtension), .registered) + XCTAssertEqual(RenderExtensionRegistry.shared.registeredIDs(), [renderExtension.id]) + + let loader = TestRenderShaderLibraryLoader() + let previousLoader = RenderShaderLibraryManager.shared.replaceLoaderForTesting(loader) + defer { + _ = RenderShaderLibraryManager.shared.replaceLoaderForTesting(previousLoader) + renderInfo.device = device + renderInfo.library = library + } + renderInfo.device = device + renderInfo.library = library + + RenderExtensionRegistry.shared.registerPipelines() + + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertEqual( + RenderExtensionRegistry.shared.shaderLibraryErrors(forExtensionID: renderExtension.id), + [ + .resourceNotFound( + libraryID: libraryID, + resource: "Missing", + subdirectory: nil + ), + ] + ) + } +} diff --git a/Tests/UntoldEngineRenderTests/TransparencyTests.swift b/Tests/UntoldEngineRenderTests/TransparencyTests.swift index 975c37cb..6c83fdc0 100644 --- a/Tests/UntoldEngineRenderTests/TransparencyTests.swift +++ b/Tests/UntoldEngineRenderTests/TransparencyTests.swift @@ -214,36 +214,36 @@ final class TransparencyRenderGraphTests: BaseRenderSetup { XCTAssertTrue(pipeline?.success ?? false, "Wireframe pipeline should be successfully initialized") } - func testTransparencyPass_existsInGameModeGraph() { + func testTransparencyPass_existsInGameModeGraph() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["transparency"], "Transparency pass should exist in the game mode graph") } - func testTransparencyPass_dependsOnLightPass() { + func testTransparencyPass_dependsOnLightPass() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertEqual(graph["transparency"]?.dependencies, ["lightPass"], "Transparency pass should depend on lightPass") } - func testTransparencyPass_hasExecutionFunction() { + func testTransparencyPass_hasExecutionFunction() throws { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["transparency"]?.execute, "Transparency pass should have an execution function") } - func testTransparencyPass_existsInAllRenderModes() { + func testTransparencyPass_existsInAllRenderModes() throws { let modes: [(UntoldImmersionMode, Bool, String)] = [ (.none, true, "environment"), (.none, false, "grid"), @@ -255,7 +255,7 @@ final class TransparencyRenderGraphTests: BaseRenderSetup { renderInfo.immersionStyle = immersionStyle renderEnvironment = useEnvironment - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["transparency"], "Transparency pass should exist in \(description) mode") @@ -275,7 +275,7 @@ final class TransparencyRenderGraphTests: BaseRenderSetup { DepthOfFieldParams.shared.enabled = true defer { DepthOfFieldParams.shared.enabled = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() let sorted = try topologicalSortGraph(graph: graph) let order = sorted.map(\.id) @@ -301,13 +301,13 @@ final class TransparencyRenderGraphTests: BaseRenderSetup { "Wireframe pass must come before post-processing (depthOfField)") } - func testTransparencyPass_bypassModeDepends() { + func testTransparencyPass_bypassModeDepends() throws { renderInfo.immersionStyle = .none renderEnvironment = true bypassPostProcessing = true defer { bypassPostProcessing = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() XCTAssertNotNil(graph["transparency"], "Transparency pass should exist when bypassing post-processing") diff --git a/Tests/UntoldEngineRenderTests/UpdateARRenderingSystemTest.swift b/Tests/UntoldEngineRenderTests/UpdateARRenderingSystemTest.swift index 3a228906..be96d418 100644 --- a/Tests/UntoldEngineRenderTests/UpdateARRenderingSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/UpdateARRenderingSystemTest.swift @@ -40,7 +40,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) // Build the render graph using the same logic as UpdateARRenderingSystem - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify that AR mode does not create environment or grid passes XCTAssertNil(graph["environment"], "AR mode should not create environment pass") @@ -70,11 +70,11 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { "outputTransform should be the last pass in the sorted order") } - func testUpdateARRenderingSystem_ARModeHasNoBasePass() { + func testUpdateARRenderingSystem_ARModeHasNoBasePass() throws { // Set up AR mode renderInfo.immersionStyle = .ar - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify that AR mode has no base pass (no environment, no grid) XCTAssertNil(graph["environment"], "AR mode should not create environment pass") @@ -86,16 +86,16 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { "Shadow pass should have no dependencies in AR mode") } - func testUpdateARRenderingSystem_ARModeBehavesLikePassthrough() { + func testUpdateARRenderingSystem_ARModeBehavesLikePassthrough() throws { // Set up AR mode renderInfo.immersionStyle = .ar - let (arGraph, arFinalPassID) = buildGameModeGraph() + let (arGraph, arFinalPassID) = try buildGameModeGraph() // Set up passthrough mode for comparison renderInfo.immersionStyle = .mixed - let (passthroughGraph, passthroughFinalPassID) = buildGameModeGraph() + let (passthroughGraph, passthroughFinalPassID) = try buildGameModeGraph() // Verify both modes produce the same graph structure XCTAssertEqual(arGraph.count, passthroughGraph.count, @@ -122,7 +122,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { // Test with AR mode renderInfo.immersionStyle = .ar - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify final pass is outputTransform XCTAssertEqual(finalPassID, "outputTransform", "buildGameModeGraph should return 'outputTransform' as final pass ID") @@ -139,7 +139,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { // Test with AR mode renderInfo.immersionStyle = .ar - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Sort the graph let sortedPasses = try topologicalSortGraph(graph: graph) @@ -162,7 +162,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { for (mode, description) in modes { renderInfo.immersionStyle = mode - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify outputTransform is the final pass for all modes XCTAssertEqual(finalPassID, "outputTransform", @@ -192,7 +192,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].loadAction = .clear // This simulates what UpdateARRenderingSystem does - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify the entire graph is valid let sortedPasses = try topologicalSortGraph(graph: graph) @@ -209,7 +209,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { DepthOfFieldParams.shared.enabled = true defer { DepthOfFieldParams.shared.enabled = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Sort the graph let sortedPasses = try topologicalSortGraph(graph: graph) @@ -229,7 +229,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { DepthOfFieldParams.shared.enabled = true defer { DepthOfFieldParams.shared.enabled = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify post-processing passes exist XCTAssertNotNil(graph["depthOfField"], "Depth of field pass should exist") @@ -257,15 +257,15 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { // MARK: - Comparison Tests - func testUpdateARRenderingSystem_ARVsNoneModeBasePassDifference() { + func testUpdateARRenderingSystem_ARVsNoneModeBasePassDifference() throws { // Test AR mode renderInfo.immersionStyle = .ar - let (arGraph, _) = buildGameModeGraph() + let (arGraph, _) = try buildGameModeGraph() // Test none mode (should have environment or grid) renderInfo.immersionStyle = .none renderEnvironment = false - let (noneGraph, _) = buildGameModeGraph() + let (noneGraph, _) = try buildGameModeGraph() // AR should have no base pass XCTAssertNil(arGraph["environment"], "AR mode should not have environment pass") @@ -285,7 +285,7 @@ final class UpdateARRenderingSystemTest: BaseRenderSetup { // Verify BasePassMode correctly identifies AR mode renderInfo.immersionStyle = .ar - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // In AR mode, no base pass should be created XCTAssertNil(graph["environment"], "BasePassMode.ar should not create environment pass") diff --git a/Tests/UntoldEngineRenderTests/UpdateRenderingSystemTest.swift b/Tests/UntoldEngineRenderTests/UpdateRenderingSystemTest.swift index 365296d0..df5054d3 100644 --- a/Tests/UntoldEngineRenderTests/UpdateRenderingSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/UpdateRenderingSystemTest.swift @@ -32,7 +32,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { defer { DepthOfFieldParams.shared.enabled = false } // Build the render graph using the same logic as UpdateRenderingSystem - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify that environment mode creates the environment pass XCTAssertNotNil(graph["environment"], "Environment mode should create environment pass") @@ -71,12 +71,12 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { ]) } - func testUpdateRenderingSystem_EnvironmentModeUsesEnvironmentPass() { + func testUpdateRenderingSystem_EnvironmentModeUsesEnvironmentPass() throws { // Set up for environment rendering renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify that environment mode uses environment, not grid XCTAssertNotNil(graph["environment"], "Environment mode should use environment pass") @@ -97,7 +97,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { defer { DepthOfFieldParams.shared.enabled = false } // Build the render graph using the same logic as UpdateRenderingSystem - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify that grid mode creates the grid pass XCTAssertNotNil(graph["grid"], "Grid mode should create grid pass") @@ -136,12 +136,12 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { ]) } - func testUpdateRenderingSystem_GridModeUsesGridPass() { + func testUpdateRenderingSystem_GridModeUsesGridPass() throws { // Set up for grid rendering renderInfo.immersionStyle = .none renderEnvironment = false - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify that grid mode uses grid, not environment XCTAssertNotNil(graph["grid"], "Grid mode should use grid pass") @@ -159,7 +159,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify final pass is outputTransform XCTAssertEqual(finalPassID, "outputTransform", "buildGameModeGraph should return 'outputTransform' as final pass ID") @@ -178,7 +178,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { renderInfo.immersionStyle = .none renderEnvironment = false - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Sort the graph let sortedPasses = try topologicalSortGraph(graph: graph) @@ -200,7 +200,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { renderInfo.immersionStyle = .none renderEnvironment = useEnvironment - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify outputTransform is the final pass XCTAssertEqual(finalPassID, "outputTransform", @@ -216,34 +216,34 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { // MARK: - Render Mode Selection Tests - func testUpdateRenderingSystem_ImmersionStyleNoneDeterminesCorrectBasePass() { + func testUpdateRenderingSystem_ImmersionStyleNoneDeterminesCorrectBasePass() throws { // Test that immersionStyle .none correctly selects between environment and grid // Test environment selection renderInfo.immersionStyle = .none renderEnvironment = true - let (envGraph, _) = buildGameModeGraph() + let (envGraph, _) = try buildGameModeGraph() XCTAssertNotNil(envGraph["environment"], "Should use environment when renderEnvironment is true") XCTAssertNil(envGraph["grid"], "Should not use grid when renderEnvironment is true") // Test grid selection renderEnvironment = false - let (gridGraph, _) = buildGameModeGraph() + let (gridGraph, _) = try buildGameModeGraph() XCTAssertNotNil(gridGraph["grid"], "Should use grid when renderEnvironment is false") XCTAssertNil(gridGraph["environment"], "Should not use environment when renderEnvironment is false") } - func testUpdateRenderingSystem_EnvironmentAndGridModesHaveDifferentDependencies() { + func testUpdateRenderingSystem_EnvironmentAndGridModesHaveDifferentDependencies() throws { // Verify that environment and grid modes create different dependency chains // Environment mode renderInfo.immersionStyle = .none renderEnvironment = true - let (envGraph, _) = buildGameModeGraph() + let (envGraph, _) = try buildGameModeGraph() // Grid mode renderEnvironment = false - let (gridGraph, _) = buildGameModeGraph() + let (gridGraph, _) = try buildGameModeGraph() // Both should have shadow pass, but with different dependencies XCTAssertNotNil(envGraph["shadow"], "Environment graph should have shadow pass") @@ -263,7 +263,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { renderEnvironment = true // This simulates what UpdateRenderingSystem does - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify the entire graph is valid let sortedPasses = try topologicalSortGraph(graph: graph) @@ -285,7 +285,7 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { renderEnvironment = false // This simulates what UpdateRenderingSystem does - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify the entire graph is valid let sortedPasses = try topologicalSortGraph(graph: graph) @@ -307,8 +307,8 @@ final class UpdateRenderingSystemTest: BaseRenderSetup { renderInfo.immersionStyle = .none renderEnvironment = true - let (graph1, _) = buildGameModeGraph() - let (graph2, _) = buildGameModeGraph() + let (graph1, _) = try buildGameModeGraph() + let (graph2, _) = try buildGameModeGraph() let sorted1 = try topologicalSortGraph(graph: graph1) let sorted2 = try topologicalSortGraph(graph: graph2) diff --git a/Tests/UntoldEngineRenderTests/UpdateXRRenderingSystemTest.swift b/Tests/UntoldEngineRenderTests/UpdateXRRenderingSystemTest.swift index 01e945f9..1babb014 100644 --- a/Tests/UntoldEngineRenderTests/UpdateXRRenderingSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/UpdateXRRenderingSystemTest.swift @@ -42,7 +42,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) // Build the render graph using the same logic as UpdateXRRenderingSystem - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify that full immersion mode creates the environment pass XCTAssertNotNil(graph["environment"], "Full immersion mode should create environment pass") @@ -81,11 +81,11 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { ]) } - func testUpdateXRRenderingSystem_FullImmersionUsesEnvironmentPass() { + func testUpdateXRRenderingSystem_FullImmersionUsesEnvironmentPass() throws { // Set up XR full immersion mode renderInfo.immersionStyle = .full - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify that full immersion uses environment, not grid or none XCTAssertNotNil(graph["environment"], "Full immersion should use environment pass") @@ -114,7 +114,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) // Build the render graph using the same logic as UpdateXRRenderingSystem - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify that passthrough mode does not create environment or grid passes XCTAssertNil(graph["environment"], "Passthrough mode should not create environment pass") @@ -144,11 +144,11 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { "outputTransform should be the last pass in the sorted order") } - func testUpdateXRRenderingSystem_PassthroughHasNoBasePass() { + func testUpdateXRRenderingSystem_PassthroughHasNoBasePass() throws { // Set up XR passthrough mode renderInfo.immersionStyle = .mixed - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify that passthrough mode has no base pass (no environment, no grid) XCTAssertNil(graph["environment"], "Passthrough mode should not create environment pass") @@ -166,7 +166,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { // Test with full immersion renderInfo.immersionStyle = .full - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify final pass is outputTransform XCTAssertEqual(finalPassID, "outputTransform", "buildGameModeGraph should return 'outputTransform' as final pass ID") @@ -183,7 +183,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { // Test with passthrough mode renderInfo.immersionStyle = .mixed - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Sort the graph let sortedPasses = try topologicalSortGraph(graph: graph) @@ -205,7 +205,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { for (mode, description) in modes { renderInfo.immersionStyle = mode - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify outputTransform is the final pass XCTAssertEqual(finalPassID, "outputTransform", @@ -235,7 +235,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].loadAction = .clear // This simulates what UpdateXRRenderingSystem does - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify the entire graph is valid let sortedPasses = try topologicalSortGraph(graph: graph) @@ -259,7 +259,7 @@ final class UpdateXRRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].loadAction = .clear // This simulates what UpdateXRRenderingSystem does - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify the entire graph is valid let sortedPasses = try topologicalSortGraph(graph: graph) diff --git a/Tests/UntoldEngineRenderTests/UpdateiOSRenderingSystemTest.swift b/Tests/UntoldEngineRenderTests/UpdateiOSRenderingSystemTest.swift index 8a362959..1058d68f 100644 --- a/Tests/UntoldEngineRenderTests/UpdateiOSRenderingSystemTest.swift +++ b/Tests/UntoldEngineRenderTests/UpdateiOSRenderingSystemTest.swift @@ -29,7 +29,7 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { renderInfo.immersionStyle = .none // Build the render graph - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify essential passes exist XCTAssertNotNil(graph["shadow"], "Shadow pass should exist") @@ -51,12 +51,12 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { "outputTransform should be the last pass in the sorted order") } - func testUpdateiOSRenderingSystem_iOSModeHasBasePass() { + func testUpdateiOSRenderingSystem_iOSModeHasBasePass() throws { // Set up iOS mode renderInfo.immersionStyle = .none renderEnvironment = false - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // iOS mode should have a base pass (grid when environment is disabled) XCTAssertNotNil(graph["grid"], "iOS mode should have grid pass when environment is disabled") @@ -67,12 +67,12 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { "Shadow pass should depend on grid in iOS mode") } - func testUpdateiOSRenderingSystem_iOSModeWithEnvironment() { + func testUpdateiOSRenderingSystem_iOSModeWithEnvironment() throws { // Set up iOS mode with environment renderInfo.immersionStyle = .none renderEnvironment = true - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Should have environment pass XCTAssertNotNil(graph["environment"], "iOS mode with environment should have environment pass") @@ -88,7 +88,7 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { // Test with iOS mode renderInfo.immersionStyle = .none - let (graph, finalPassID) = buildGameModeGraph() + let (graph, finalPassID) = try buildGameModeGraph() // Verify final pass is outputTransform XCTAssertEqual(finalPassID, "outputTransform", "buildGameModeGraph should return 'outputTransform' as final pass ID") @@ -105,7 +105,7 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { // Test with iOS mode renderInfo.immersionStyle = .none - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Sort the graph let sortedPasses = try topologicalSortGraph(graph: graph) @@ -121,7 +121,7 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { DepthOfFieldParams.shared.enabled = true defer { DepthOfFieldParams.shared.enabled = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify post-processing passes exist XCTAssertNotNil(graph["depthOfField"], "Depth of field pass should exist") @@ -153,7 +153,7 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { DepthOfFieldParams.shared.enabled = true defer { DepthOfFieldParams.shared.enabled = false } - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Sort the graph let sortedPasses = try topologicalSortGraph(graph: graph) @@ -182,7 +182,7 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { renderPassDescriptor.colorAttachments[0].texture = renderer.metalView.currentDrawable?.texture renderPassDescriptor.colorAttachments[0].loadAction = .clear - let (graph, _) = buildGameModeGraph() + let (graph, _) = try buildGameModeGraph() // Verify the entire graph is valid let sortedPasses = try topologicalSortGraph(graph: graph) @@ -193,15 +193,15 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { // MARK: - Comparison Tests - func testUpdateiOSRenderingSystem_iOSVsARModeDifference() { + func testUpdateiOSRenderingSystem_iOSVsARModeDifference() throws { // Test iOS mode renderInfo.immersionStyle = .none renderEnvironment = false - let (iosGraph, _) = buildGameModeGraph() + let (iosGraph, _) = try buildGameModeGraph() // Test AR mode renderInfo.immersionStyle = .ar - let (arGraph, _) = buildGameModeGraph() + let (arGraph, _) = try buildGameModeGraph() // iOS should have base pass (grid) XCTAssertNotNil(iosGraph["grid"], "iOS mode should have grid pass") @@ -217,14 +217,14 @@ final class UpdateiOSRenderingSystemTest: BaseRenderSetup { "AR shadow should have no dependencies") } - func testUpdateiOSRenderingSystem_iOSVsMacOSModeSimilarity() { + func testUpdateiOSRenderingSystem_iOSVsMacOSModeSimilarity() throws { // Test iOS mode (.none) renderInfo.immersionStyle = .none renderEnvironment = true - let (iosGraph, iosPreCompID) = buildGameModeGraph() + let (iosGraph, iosPreCompID) = try buildGameModeGraph() // macOS typically also uses .none, so they should be similar - let (macosGraph, macosPreCompID) = buildGameModeGraph() + let (macosGraph, macosPreCompID) = try buildGameModeGraph() // Both should have the same structure XCTAssertEqual(iosGraph.count, macosGraph.count, diff --git a/Tests/UntoldEngineRenderTests/WholeRenderGraphValidationTest.swift b/Tests/UntoldEngineRenderTests/WholeRenderGraphValidationTest.swift new file mode 100644 index 00000000..53eec1e7 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/WholeRenderGraphValidationTest.swift @@ -0,0 +1,287 @@ +// +// WholeRenderGraphValidationTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +@testable import UntoldEngine +import XCTest + +private final class TestHazardRenderExtension: RenderExtension, @unchecked Sendable { + let id: String + let textureID: RenderTextureResourceID + let readerPassID: String + let writerPassID: String + let writesBeforeReading: Bool + + init(id: String, writesBeforeReading: Bool) { + self.id = id + textureID = RenderTextureResourceID("\(id).texture") + readerPassID = "\(id).reader" + writerPassID = "\(id).writer" + self.writesBeforeReading = writesBeforeReading + } + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: textureID, + size: .fixed(width: 4, height: 4), + pixelFormat: .rgba8Unorm, + usage: [.shaderRead, .shaderWrite] + ) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + if writesBeforeReading { + builder.addPass( + id: writerPassID, + stage: .beforeOutput, + resources: [.texture(textureID, access: .write)], + execute: nil + ) + builder.addPass( + id: readerPassID, + stage: .beforeOutput, + resources: [.texture(textureID, access: .read)], + execute: nil + ) + } else { + builder.addPass( + id: readerPassID, + stage: .beforeOutput, + resources: [.texture(textureID, access: .read)], + execute: nil + ) + builder.addPass( + id: writerPassID, + stage: .beforeOutput, + resources: [.texture(textureID, access: .write)], + execute: nil + ) + } + } +} + +private struct TestHazardRenderPlugin: RenderExtensionPlugin { + let manifest: RenderExtensionPluginManifest + let renderExtension: TestHazardRenderExtension + + init(id: String) { + manifest = RenderExtensionPluginManifest( + id: id, + displayName: "Hazard Test Plugin", + version: RenderExtensionPluginVersion(major: 1, minor: 0, patch: 0) + ) + renderExtension = TestHazardRenderExtension( + id: "\(id).surface", + writesBeforeReading: false + ) + } + + func makeRenderExtensions() -> [any RenderExtension] { + [renderExtension] + } +} + +final class WholeRenderGraphValidationTest: BaseRenderSetup { + func testValidationReportsEveryMissingDependencyWithOwner() { + let first = RenderPass( + id: "first", + dependencies: ["missing-a"], + execute: nil, + owner: .renderExtension("test.first.extension") + ) + let second = RenderPass( + id: "second", + dependencies: ["missing-b"], + execute: nil, + owner: .renderExtension("test.second.extension") + ) + + XCTAssertEqual( + analyzeRenderGraph([first.id: first, second.id: second]) + .validationReport.diagnostics, + [ + RenderGraphValidationDiagnostic( + error: .missingDependency( + passID: first.id, + dependencyID: "missing-a" + ), + ownerID: "test.first.extension" + ), + RenderGraphValidationDiagnostic( + error: .missingDependency( + passID: second.id, + dependencyID: "missing-b" + ), + ownerID: "test.second.extension" + ), + ] + ) + } + + func testReadBeforeWriteIsAttributedToReaderOwner() { + let textureID: RenderTextureResourceID = "test.hazard.read-before-write" + let reader = RenderPass( + id: "reader", + dependencies: [], + execute: nil, + resourceUsages: [.texture(textureID, access: .read)], + owner: .renderExtension("test.reader.extension") + ) + let writer = RenderPass( + id: "writer", + dependencies: [], + execute: nil, + resourceUsages: [.texture(textureID, access: .write)], + owner: .renderExtension("test.writer.extension") + ) + + XCTAssertEqual( + validateRenderGraphResourceHazards([reader, writer]), + [ + RenderGraphValidationDiagnostic( + error: .readBeforeWrite( + passID: reader.id, + kind: .texture, + resourceID: textureID.rawValue + ), + ownerID: "test.reader.extension" + ), + ] + ) + } + + func testValidationRejectsUnscheduledWritersDeterministically() { + let bufferID: RenderBufferResourceID = "test.hazard.multiple-writers" + let first = RenderPass( + id: "first", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)], + owner: .renderExtension("test.writer.extension") + ) + let second = RenderPass( + id: "second", + dependencies: [], + execute: nil, + resourceUsages: [.buffer(bufferID, access: .write)], + owner: .renderExtension("test.writer.extension") + ) + + XCTAssertEqual( + validateRenderGraphResourceHazards([first, second]), + [ + RenderGraphValidationDiagnostic( + error: .unorderedResourceWrites( + kind: .buffer, + resourceID: bufferID.rawValue, + firstPassID: first.id, + secondPassID: second.id + ), + ownerID: "test.writer.extension" + ), + ] + ) + } + + func testOrderedWriterThenReaderIsValid() { + let textureID: RenderTextureResourceID = "test.hazard.ordered" + let writer = RenderPass( + id: "writer", + dependencies: [], + execute: nil, + resourceUsages: [.texture(textureID, access: .write)] + ) + let reader = RenderPass( + id: "reader", + dependencies: [writer.id], + execute: nil, + resourceUsages: [.texture(textureID, access: .read)] + ) + + XCTAssertTrue(validateRenderGraphResourceHazards([writer, reader]).isEmpty) + } + + func testInvalidStandaloneExtensionIsRejectedAndGraphIsRebuilt() throws { + let renderExtension = TestHazardRenderExtension( + id: "test.hazard.standalone", + writesBeforeReading: false + ) + setRendering(.extensions(.register(renderExtension))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph[renderExtension.readerPassID]) + XCTAssertNil(graph[renderExtension.writerPassID]) + XCTAssertFalse(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertEqual( + RenderExtensionRegistry.shared.graphValidationErrors( + forExtensionID: renderExtension.id + ), + [ + .readBeforeWrite( + passID: renderExtension.readerPassID, + kind: .texture, + resourceID: renderExtension.textureID.rawValue + ), + ] + ) + XCTAssertEqual( + RenderResourceRegistry.shared.textureState(renderExtension.textureID), + .released + ) + } + + func testValidStandaloneExtensionSurvivesWholeGraphValidation() throws { + let renderExtension = TestHazardRenderExtension( + id: "test.hazard.valid", + writesBeforeReading: true + ) + setRendering(.extensions(.register(renderExtension))) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNotNil(graph[renderExtension.writerPassID]) + XCTAssertNotNil(graph[renderExtension.readerPassID]) + XCTAssertTrue(RenderExtensionRegistry.shared.registeredIDs().contains(renderExtension.id)) + XCTAssertTrue( + RenderExtensionRegistry.shared.graphValidationErrors( + forExtensionID: renderExtension.id + ).isEmpty + ) + } + + func testInvalidPluginIsRejectedWithWholeGraphDiagnostics() throws { + let plugin = TestHazardRenderPlugin(id: "com.untold.hazard") + XCTAssertEqual(RenderExtensionPluginRegistry.shared.install(plugin), .installed) + + let (graph, _) = try buildGameModeGraph() + + XCTAssertNil(graph[plugin.renderExtension.readerPassID]) + XCTAssertNil(graph[plugin.renderExtension.writerPassID]) + XCTAssertTrue(RenderExtensionPluginRegistry.shared.installedPluginIDs().isEmpty) + XCTAssertEqual( + RenderExtensionPluginRegistry.shared.failure( + forPluginID: plugin.manifest.id + )?.graphValidationErrors, + [ + .readBeforeWrite( + passID: plugin.renderExtension.readerPassID, + kind: .texture, + resourceID: plugin.renderExtension.textureID.rawValue + ), + ] + ) + } +} diff --git a/Tests/UntoldEngineTests/BuildSystemTests.swift b/Tests/UntoldEngineTests/BuildSystemTests.swift index daf3184c..dec59d86 100644 --- a/Tests/UntoldEngineTests/BuildSystemTests.swift +++ b/Tests/UntoldEngineTests/BuildSystemTests.swift @@ -288,6 +288,32 @@ final class BuildSystemTests: XCTestCase { "GameViewController.swift should initialize GameScene") } + func testMacOSPackageSwiftIncludesShaderSupportDependency() { + // Given: Build settings for a macOS project + let settings = BuildSettings( + projectName: "MyGameProject", + bundleIdentifier: "com.test.game", + outputPath: tempDirectory, + target: .macOS(deployment: .v14) + ) + + // When: Getting template files + let templateFiles = BuildTemplates.getTemplateFiles(for: settings.target) + + // Then: Package.swift should include both the engine and shader support products + guard let packageContent = templateFiles["Package.swift"] else { + XCTFail("macOS template should include Package.swift") + return + } + + XCTAssertTrue(packageContent.contains(".product(name: \"UntoldEngine\", package: \"UntoldEngine\")"), + "Package.swift should depend on UntoldEngine") + XCTAssertTrue(packageContent.contains(".product(name: \"UntoldEngineShaderSupport\", package: \"UntoldEngine\")"), + "Package.swift should depend on UntoldEngineShaderSupport for extension shader includes") + XCTAssertTrue(packageContent.contains("defaultLocalization: \"en\""), + "Package.swift should set a default localization because the template includes Base.lproj") + } + func testMainStoryboardGeneratedCorrectly() { // Given: Build settings for a project let projectName = "MyGameProject" @@ -383,11 +409,12 @@ final class BuildSystemTests: XCTestCase { // When: Generating XcodeGen YAML spec let yamlContent = try XcodeGenProjectSpec.generateYAML(settings: settings) - // Then: Verify the YAML includes Base.lproj as a resource - XCTAssertTrue(yamlContent.contains("path: Sources/\(projectName)/Base.lproj"), - "XcodeGen spec should include Base.lproj path") + // Then: Verify the YAML does not add Base.lproj as a raw folder resource. + // Storyboards are compiled from the Sources tree. + XCTAssertFalse(yamlContent.contains("path: Sources/\(projectName)/Base.lproj"), + "XcodeGen spec should not add Base.lproj as a raw folder resource") XCTAssertTrue(yamlContent.contains("type: folder"), - "XcodeGen spec should specify folder type for resources") + "XcodeGen spec should still specify folder type for GameData resources") XCTAssertTrue(yamlContent.contains("buildPhase: resources"), "XcodeGen spec should include buildPhase: resources") @@ -410,6 +437,29 @@ final class BuildSystemTests: XCTestCase { "XcodeGen spec should specify application type") } + func testXcodeProjectSpecIncludesShaderSupportDependency() throws { + // Given: Build settings for a standard app target + let settings = BuildSettings( + projectName: "MyGameProject", + bundleIdentifier: "com.test.game", + outputPath: tempDirectory, + target: .macOS(deployment: .v14) + ) + + // When: Generating XcodeGen YAML spec + let yamlContent = try XcodeGenProjectSpec.generateYAML(settings: settings) + + // Then: The app target should depend on shader support alongside UntoldEngine + XCTAssertTrue(yamlContent.contains("product: UntoldEngine"), + "XcodeGen spec should depend on UntoldEngine") + XCTAssertTrue(yamlContent.contains("product: UntoldEngineShaderSupport"), + "XcodeGen spec should depend on UntoldEngineShaderSupport for extension shader includes") + XCTAssertTrue(yamlContent.contains("UNTOLD_ENGINE_PACKAGE_ROOT: \"$(BUILD_DIR)/../../SourcePackages/checkouts/UntoldEngine\""), + "XcodeGen spec should expose the engine package root as an overridable build setting") + XCTAssertTrue(yamlContent.contains("MTL_HEADER_SEARCH_PATHS: \"$(inherited) $(UNTOLD_ENGINE_PACKAGE_ROOT)/Sources/UntoldEngineShaderSupport/include\""), + "XcodeGen spec should expose shader support headers to Metal shaders without absolute paths") + } + // MARK: - iOS Template Tests func testIOSAppDelegateSwiftGeneratedWithExpectedContent() { @@ -1008,6 +1058,8 @@ final class BuildSystemTests: XCTestCase { "visionOS single-platform should depend on UntoldEngineXR product") XCTAssertTrue(yamlContent.contains("product: UntoldEngineAR"), "visionOS single-platform should depend on UntoldEngineAR product") + XCTAssertFalse(yamlContent.contains("product: UntoldEngineShaderSupport"), + "visionOS single-platform should not add shader support unless it depends on the base UntoldEngine product") } // MARK: - Multi-Platform Tests @@ -1232,6 +1284,8 @@ final class BuildSystemTests: XCTestCase { var foundVisionXR = false var foundMacOSEngine = false var foundIOSEngine = false + var foundMacOSShaderSupport = false + var foundIOSShaderSupport = false for line in lines { let trimmedLine = line.trimmingCharacters(in: .whitespaces) @@ -1269,6 +1323,12 @@ final class BuildSystemTests: XCTestCase { if inMacOSTarget { foundMacOSEngine = true } if inIOSTarget { foundIOSEngine = true } } + if trimmedLine == "product: UntoldEngineShaderSupport" { + XCTAssertTrue(inMacOSTarget || inIOSTarget, + "UntoldEngineShaderSupport product should only be in base macOS or iOS targets") + if inMacOSTarget { foundMacOSShaderSupport = true } + if inIOSTarget { foundIOSShaderSupport = true } + } if trimmedLine == "product: UntoldEngineAR" { XCTAssertTrue(inIOSARTarget || inVisionOSTarget, "UntoldEngineAR product should only be in iOS AR or visionOS targets") @@ -1283,6 +1343,8 @@ final class BuildSystemTests: XCTestCase { XCTAssertTrue(foundMacOSEngine, "macOS target should include UntoldEngine product") XCTAssertTrue(foundIOSEngine, "iOS target should include UntoldEngine product") + XCTAssertTrue(foundMacOSShaderSupport, "macOS target should include UntoldEngineShaderSupport product") + XCTAssertTrue(foundIOSShaderSupport, "iOS target should include UntoldEngineShaderSupport product") XCTAssertTrue(foundIOSARAR, "iOS AR target should include UntoldEngineAR") XCTAssertTrue(foundVisionXR, "visionOS target should include UntoldEngineXR") XCTAssertTrue(foundVisionAR, "visionOS target should include UntoldEngineAR") diff --git a/Tests/UntoldEngineTests/ECSTests.swift b/Tests/UntoldEngineTests/ECSTests.swift index e82eb5f3..05f3fc46 100644 --- a/Tests/UntoldEngineTests/ECSTests.swift +++ b/Tests/UntoldEngineTests/ECSTests.swift @@ -11,6 +11,12 @@ @testable import UntoldEngine import XCTest +private final class TestExtensionComponent: Component { + var value: Float = 0.0 + + required init() {} +} + @MainActor final class ECSTests: XCTestCase { override func setUp() async throws { @@ -269,4 +275,51 @@ final class ECSTests: XCTestCase { XCTAssertFalse(results.contains(entityId), "Pending-destroy entity should not appear in query results") } + + func testGenericComponentHelpersSupportExtensionComponents() { + let entityA = createEntity() + let entityB = createEntity() + + registerComponent(entityId: entityA, componentType: TestExtensionComponent.self) + registerComponent(entityId: entityA, componentType: RenderComponent.self) + registerComponent(entityId: entityB, componentType: RenderComponent.self) + + let component = getEntityComponent(entityId: entityA, componentType: TestExtensionComponent.self) + component?.value = 42.0 + + XCTAssertEqual( + getEntityComponent(entityId: entityA, componentType: TestExtensionComponent.self)?.value, + 42.0 + ) + + let queried = queryEntities(with: [TestExtensionComponent.self, RenderComponent.self]) + XCTAssertTrue(queried.contains(entityA)) + XCTAssertFalse(queried.contains(entityB)) + } + + func testGenericRemoveEntityComponentUpdatesMaskAndQueryIndex() { + let entityId = createEntity() + + registerComponent(entityId: entityId, componentType: TestExtensionComponent.self) + XCTAssertTrue(hasComponent(entityId: entityId, componentType: TestExtensionComponent.self)) + + removeEntityComponent(entityId: entityId, componentType: TestExtensionComponent.self) + + XCTAssertFalse(hasComponent(entityId: entityId, componentType: TestExtensionComponent.self)) + XCTAssertNil(getEntityComponent(entityId: entityId, componentType: TestExtensionComponent.self)) + XCTAssertFalse(queryEntities(with: [TestExtensionComponent.self]).contains(entityId)) + } + + func testGenericRegisteredComponentIsCleanedUpOnDestroy() { + let entityId = createEntity() + + registerComponent(entityId: entityId, componentType: TestExtensionComponent.self) + XCTAssertTrue(ComponentRegistry.hasCleanupHandler(for: TestExtensionComponent.self)) + + destroyEntity(entityId: entityId) + finalizePendingDestroys() + + XCTAssertFalse(queryEntities(with: [TestExtensionComponent.self]).contains(entityId)) + XCTAssertNil(getEntityComponent(entityId: entityId, componentType: TestExtensionComponent.self)) + } } diff --git a/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift b/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift new file mode 100644 index 00000000..81d53561 --- /dev/null +++ b/Tests/UntoldEngineTests/ExternalRenderExtensionPackageTests.swift @@ -0,0 +1,46 @@ +import Foundation +import XCTest + +@MainActor +final class ExternalRenderExtensionPackageTests: XCTestCase { + func testExternalWaterRenderPluginPackageBuildsAndPassesItsContractTests() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let fixture = repositoryRoot + .appendingPathComponent( + "Examples/RenderingExtensions/SwiftPackagePlugin", + isDirectory: true + ) + let scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("WaterRenderPlugin-(UUID().uuidString)", isDirectory: true) + let logURL = scratch.appendingPathComponent("swift-test.log") + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: logURL.path, contents: nil) + defer { try? FileManager.default.removeItem(at: scratch) } + + let log = try FileHandle(forWritingTo: logURL) + defer { try? log.close() } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "swift", "test", + "--package-path", fixture.path, + "--scratch-path", scratch.appendingPathComponent("build").path, + ] + process.standardOutput = log + process.standardError = log + try process.run() + process.waitUntilExit() + try log.synchronize() + + let output = try String(contentsOf: logURL, encoding: .utf8) + XCTAssertEqual( + process.terminationStatus, + 0, + "External render-extension fixture failed:\n\(output)" + ) + } +} diff --git a/Tests/UntoldEngineTests/GraphTest.swift b/Tests/UntoldEngineTests/GraphTest.swift index b222334e..32fb6da4 100644 --- a/Tests/UntoldEngineTests/GraphTest.swift +++ b/Tests/UntoldEngineTests/GraphTest.swift @@ -107,8 +107,8 @@ final class GraphTest: XCTestCase { let graph = [nodeA.id: nodeA, nodeB.id: nodeB, nodeC.id: nodeC] XCTAssertThrowsError(try topologicalSortGraph(graph: graph)) { error in - XCTAssertTrue(error is GraphError) - if case let GraphError.cycleDetected(node) = error { + XCTAssertTrue(error is RenderGraphError) + if case let RenderGraphError.cycleDetected(node) = error { print("Cycle correctly detected at: \(node)") } else { XCTFail("Expected a cycleDetected error") @@ -116,6 +116,37 @@ final class GraphTest: XCTestCase { } } + func testMissingDependencyDetection() { + let graph = [ + "dependent": RenderPass( + id: "dependent", + dependencies: ["missing"], + execute: nil + ), + ] + + XCTAssertThrowsError(try topologicalSortGraph(graph: graph)) { error in + XCTAssertEqual( + error as? RenderGraphError, + .missingDependency(passID: "dependent", dependencyID: "missing") + ) + } + } + + func testGraphErrorDescriptionsContainActionablePassIDs() { + XCTAssertEqual( + RenderGraphError.duplicatePassID("duplicate").description, + "Duplicate render pass id: duplicate" + ) + XCTAssertEqual( + RenderGraphError.missingDependency( + passID: "dependent", + dependencyID: "missing" + ).description, + "Render pass 'dependent' depends on missing pass 'missing'" + ) + } + func testGraphWithSharedDependency_usingHelper() throws { var graph = [String: RenderPass]() diff --git a/Tests/UntoldEngineTests/IOSTouchInputSystemTests.swift b/Tests/UntoldEngineTests/IOSTouchInputSystemTests.swift new file mode 100644 index 00000000..be14a91f --- /dev/null +++ b/Tests/UntoldEngineTests/IOSTouchInputSystemTests.swift @@ -0,0 +1,292 @@ +// +// IOSTouchInputSystemTests.swift +// UntoldEngineTests +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +// MARK: - IOSTouchState Struct Tests (platform-independent) + +final class IOSTouchStateTests: XCTestCase { + // MARK: M1 — Struct defaults + + func test_iosTouchState_defaultValues_allFalseAndZero() { + let state = IOSTouchState() + + XCTAssertFalse(state.isDragging) + XCTAssertEqual(state.dragX, 0) + XCTAssertEqual(state.dragY, 0) + XCTAssertEqual(state.dragDeltaX, 0) + XCTAssertEqual(state.dragDeltaY, 0) + XCTAssertNil(state.dragGestureState) + + XCTAssertFalse(state.tapped) + XCTAssertEqual(state.tapX, 0) + XCTAssertEqual(state.tapY, 0) + + XCTAssertFalse(state.doubleTapped) + XCTAssertEqual(state.doubleTapX, 0) + XCTAssertEqual(state.doubleTapY, 0) + + XCTAssertFalse(state.twoFingerPanning) + XCTAssertEqual(state.twoFingerDeltaX, 0) + XCTAssertEqual(state.twoFingerDeltaY, 0) + + XCTAssertFalse(state.isPinching) + XCTAssertEqual(state.pinchScaleDelta, 0) + } + + // MARK: M1 — Field mutations are independent + + func test_iosTouchState_dragging_doesNotAffectOtherFields() { + var state = IOSTouchState() + state.isDragging = true + state.dragX = 120 + state.dragY = 80 + state.dragDeltaX = 5 + state.dragDeltaY = -3 + state.dragGestureState = .changed + + XCTAssertFalse(state.tapped) + XCTAssertFalse(state.doubleTapped) + XCTAssertFalse(state.twoFingerPanning) + XCTAssertFalse(state.isPinching) + XCTAssertEqual(state.tapX, 0) + XCTAssertEqual(state.twoFingerDeltaX, 0) + XCTAssertEqual(state.pinchScaleDelta, 0) + } + + func test_iosTouchState_pinching_doesNotAffectDrag() { + var state = IOSTouchState() + state.isPinching = true + state.pinchScaleDelta = 0.15 + + XCTAssertFalse(state.isDragging) + XCTAssertFalse(state.twoFingerPanning) + XCTAssertEqual(state.dragX, 0) + XCTAssertEqual(state.twoFingerDeltaX, 0) + } + + func test_iosTouchState_twoFingerPan_doesNotAffectTap() { + var state = IOSTouchState() + state.twoFingerPanning = true + state.twoFingerDeltaX = 12 + state.twoFingerDeltaY = -7 + + XCTAssertFalse(state.tapped) + XCTAssertFalse(state.doubleTapped) + XCTAssertEqual(state.tapX, 0) + } + + // MARK: M1 — Reset via reassignment + + func test_iosTouchState_reset_clearsAllFields() { + var state = IOSTouchState() + state.isDragging = true + state.dragX = 200 + state.tapped = true + state.twoFingerPanning = true + state.isPinching = true + state.pinchScaleDelta = 0.5 + state.dragGestureState = .began + + state = IOSTouchState() + + XCTAssertFalse(state.isDragging) + XCTAssertEqual(state.dragX, 0) + XCTAssertFalse(state.tapped) + XCTAssertFalse(state.twoFingerPanning) + XCTAssertFalse(state.isPinching) + XCTAssertEqual(state.pinchScaleDelta, 0) + XCTAssertNil(state.dragGestureState) + } + + // MARK: M2 — PanGestureState assignments + + func test_iosTouchState_dragGestureState_canBeSetAndCleared() { + var state = IOSTouchState() + + state.dragGestureState = .began + XCTAssertEqual(state.dragGestureState, .began) + + state.dragGestureState = .changed + XCTAssertEqual(state.dragGestureState, .changed) + + state.dragGestureState = .ended + XCTAssertEqual(state.dragGestureState, .ended) + + state.dragGestureState = nil + XCTAssertNil(state.dragGestureState) + } + + // MARK: M2 — Tap position tracking + + func test_iosTouchState_tap_positionStoredCorrectly() { + var state = IOSTouchState() + state.tapped = true + state.tapX = 320 + state.tapY = 240 + + XCTAssertTrue(state.tapped) + XCTAssertEqual(state.tapX, 320, accuracy: 0.001) + XCTAssertEqual(state.tapY, 240, accuracy: 0.001) + } + + func test_iosTouchState_doubleTap_positionStoredCorrectly() { + var state = IOSTouchState() + state.doubleTapped = true + state.doubleTapX = 160 + state.doubleTapY = 90 + + XCTAssertTrue(state.doubleTapped) + XCTAssertEqual(state.doubleTapX, 160, accuracy: 0.001) + XCTAssertEqual(state.doubleTapY, 90, accuracy: 0.001) + // Tap fields must not be contaminated + XCTAssertFalse(state.tapped) + XCTAssertEqual(state.tapX, 0) + } + + // MARK: M2 — Pinch scale delta sign + + func test_iosTouchState_pinchScaleDelta_canBeNegative() { + var state = IOSTouchState() + state.isPinching = true + state.pinchScaleDelta = -0.08 + + XCTAssertTrue(state.isPinching) + XCTAssertEqual(state.pinchScaleDelta, -0.08, accuracy: 0.0001) + } + + func test_iosTouchState_pinchScaleDelta_canBePositive() { + var state = IOSTouchState() + state.isPinching = true + state.pinchScaleDelta = 0.12 + + XCTAssertEqual(state.pinchScaleDelta, 0.12, accuracy: 0.0001) + } + + // MARK: M3 — InputSystem stored property + + func test_inputSystem_iosTouchState_defaultValues() { + let input = InputSystem.shared + // Reset before reading + input.iosTouchState = IOSTouchState() + + let state = input.iosTouchState + XCTAssertFalse(state.isDragging) + XCTAssertFalse(state.tapped) + XCTAssertFalse(state.doubleTapped) + XCTAssertFalse(state.twoFingerPanning) + XCTAssertFalse(state.isPinching) + } + + func test_inputSystem_iosTouchState_canBeDirectlyMutated() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + + input.iosTouchState.isDragging = true + input.iosTouchState.dragX = 50 + input.iosTouchState.dragY = 100 + + XCTAssertTrue(input.iosTouchState.isDragging) + XCTAssertEqual(input.iosTouchState.dragX, 50, accuracy: 0.001) + XCTAssertEqual(input.iosTouchState.dragY, 100, accuracy: 0.001) + } + + func test_inputSystem_iosTouchState_resetClearsAllMutations() { + let input = InputSystem.shared + input.iosTouchState.isDragging = true + input.iosTouchState.isPinching = true + input.iosTouchState.tapped = true + + input.iosTouchState = IOSTouchState() + + XCTAssertFalse(input.iosTouchState.isDragging) + XCTAssertFalse(input.iosTouchState.isPinching) + XCTAssertFalse(input.iosTouchState.tapped) + } + + // MARK: M3 — getIOSTouchState free function + + func test_getIOSTouchState_returnsSameValueAsSharedProperty() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + input.iosTouchState.dragX = 77 + input.iosTouchState.isDragging = true + + let queried = getIOSTouchState() + XCTAssertEqual(queried.dragX, 77, accuracy: 0.001) + XCTAssertTrue(queried.isDragging) + } + + func test_getIOSTouchState_defaultState_matchesStruct() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + + let state = getIOSTouchState() + XCTAssertFalse(state.isDragging) + XCTAssertFalse(state.tapped) + XCTAssertEqual(state.pinchScaleDelta, 0) + } + + // MARK: M4 — Cross-contamination: iOS state does not bleed into mouse/keyboard + + func test_iosTouchState_mutation_doesNotAffectKeyState() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + let keysBefore = input.keyState + + input.iosTouchState.isDragging = true + input.iosTouchState.tapped = true + + // Key state must be unchanged + XCTAssertEqual(input.keyState.leftMousePressed, keysBefore.leftMousePressed) + XCTAssertEqual(input.keyState.wPressed, keysBefore.wPressed) + } + + func test_iosTouchState_mutation_doesNotAffectMouseFields() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + let mouseBefore = (input.mouseX, input.mouseY, input.mouseActive) + + input.iosTouchState.dragX = 300 + input.iosTouchState.dragY = 400 + input.iosTouchState.isDragging = true + + // Direct mouse fields must be unchanged + XCTAssertEqual(input.mouseX, mouseBefore.0) + XCTAssertEqual(input.mouseY, mouseBefore.1) + XCTAssertEqual(input.mouseActive, mouseBefore.2) + } + + func test_iosTouchState_mutation_doesNotAffectGameControllerState() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + let aBeforePress = input.gameControllerState.aPressed + + input.iosTouchState.tapped = true + input.iosTouchState.doubleTapped = true + + XCTAssertEqual(input.gameControllerState.aPressed, aBeforePress) + } + + // MARK: M4 — Snapshot semantics + + func test_getIOSTouchState_returnsValueSnapshot_notLiveReference() { + let input = InputSystem.shared + input.iosTouchState = IOSTouchState() + input.iosTouchState.pinchScaleDelta = 0.1 + + let snapshot = getIOSTouchState() + input.iosTouchState.pinchScaleDelta = 0.9 + + // Snapshot should still hold original value — IOSTouchState is a value type + XCTAssertEqual(snapshot.pinchScaleDelta, 0.1, accuracy: 0.001) + } +} diff --git a/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift b/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift new file mode 100644 index 00000000..ab6e4987 --- /dev/null +++ b/Tests/UntoldEngineTests/PSVR2InputSystemTests.swift @@ -0,0 +1,673 @@ +// +// PSVR2InputSystemTests.swift +// UntoldEngineTests +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +@MainActor +final class PSVR2InputSystemTests: XCTestCase { + override func setUp() async throws { + let input = InputSystem.shared + input.psvr2SenseControllerState = PSVR2SenseControllerState() + input.psvr2MotionEnabled = false + input.psvr2Motion = nil + } + + // MARK: - PSVR2SenseControllerState default values + + func test_psvr2State_struct_defaultValues() { + let state = PSVR2SenseControllerState() + + XCTAssertFalse(state.isConnected) + + XCTAssertFalse(state.createButtonPressed) + XCTAssertFalse(state.homeButtonPressed) + XCTAssertFalse(state.touchpadButtonPressed) + + XCTAssertEqual(state.touchpadX, 0) + XCTAssertEqual(state.touchpadY, 0) + XCTAssertFalse(state.touchpadTouched) + + XCTAssertEqual(state.leftAdaptiveTriggerValue, 0) + XCTAssertEqual(state.rightAdaptiveTriggerValue, 0) + + XCTAssertEqual(state.motionGravityX, 0) + XCTAssertEqual(state.motionGravityY, 0) + XCTAssertEqual(state.motionGravityZ, 0) + XCTAssertEqual(state.motionRotationRateX, 0) + XCTAssertEqual(state.motionRotationRateY, 0) + XCTAssertEqual(state.motionRotationRateZ, 0) + } + + func test_psvr2StateOnInputSystem_defaultValues() { + let state = InputSystem.shared.psvr2SenseControllerState + + XCTAssertFalse(state.isConnected) + XCTAssertFalse(state.createButtonPressed) + XCTAssertFalse(state.homeButtonPressed) + XCTAssertFalse(state.touchpadButtonPressed) + XCTAssertFalse(state.touchpadTouched) + XCTAssertEqual(state.touchpadX, 0) + XCTAssertEqual(state.touchpadY, 0) + XCTAssertEqual(state.leftAdaptiveTriggerValue, 0) + XCTAssertEqual(state.rightAdaptiveTriggerValue, 0) + } + + func test_psvr2MotionEnabled_defaultsFalse() { + XCTAssertFalse(InputSystem.shared.psvr2MotionEnabled) + } + + // MARK: - Button fields + + func test_psvr2State_buttons_flipIndependently() { + var state = PSVR2SenseControllerState() + + state.createButtonPressed = true + XCTAssertTrue(state.createButtonPressed) + XCTAssertFalse(state.homeButtonPressed) + XCTAssertFalse(state.touchpadButtonPressed) + + state.homeButtonPressed = true + XCTAssertTrue(state.createButtonPressed) + XCTAssertTrue(state.homeButtonPressed) + XCTAssertFalse(state.touchpadButtonPressed) + + state.touchpadButtonPressed = true + XCTAssertTrue(state.createButtonPressed) + XCTAssertTrue(state.homeButtonPressed) + XCTAssertTrue(state.touchpadButtonPressed) + + state.createButtonPressed = false + XCTAssertFalse(state.createButtonPressed) + XCTAssertTrue(state.homeButtonPressed) + XCTAssertTrue(state.touchpadButtonPressed) + } + + // MARK: - Touchpad surface + + func test_psvr2State_touchpad_positionAndTouched() { + var state = PSVR2SenseControllerState() + + state.touchpadX = 0.75 + state.touchpadY = -0.5 + state.touchpadTouched = true + + XCTAssertEqual(state.touchpadX, 0.75, accuracy: 0.0001) + XCTAssertEqual(state.touchpadY, -0.5, accuracy: 0.0001) + XCTAssertTrue(state.touchpadTouched) + + state.touchpadX = -1.0 + state.touchpadY = 1.0 + XCTAssertEqual(state.touchpadX, -1.0, accuracy: 0.0001) + XCTAssertEqual(state.touchpadY, 1.0, accuracy: 0.0001) + + state.touchpadTouched = false + XCTAssertFalse(state.touchpadTouched) + } + + // MARK: - Adaptive trigger values + + func test_psvr2State_adaptiveTriggers_trackIndependently() { + var state = PSVR2SenseControllerState() + + state.leftAdaptiveTriggerValue = 0.3 + state.rightAdaptiveTriggerValue = 0.9 + + XCTAssertEqual(state.leftAdaptiveTriggerValue, 0.3, accuracy: 0.0001) + XCTAssertEqual(state.rightAdaptiveTriggerValue, 0.9, accuracy: 0.0001) + + state.leftAdaptiveTriggerValue = 1.0 + XCTAssertEqual(state.leftAdaptiveTriggerValue, 1.0, accuracy: 0.0001) + XCTAssertEqual(state.rightAdaptiveTriggerValue, 0.9, accuracy: 0.0001) + } + + // MARK: - Motion fields + + func test_psvr2State_motionFields_gravityAndRotationRate() { + var state = PSVR2SenseControllerState() + + state.motionGravityX = 0.1 + state.motionGravityY = -9.8 + state.motionGravityZ = 0.05 + state.motionRotationRateX = 0.3 + state.motionRotationRateY = -0.1 + state.motionRotationRateZ = 0.7 + + XCTAssertEqual(state.motionGravityX, 0.1, accuracy: 0.0001) + XCTAssertEqual(state.motionGravityY, -9.8, accuracy: 0.0001) + XCTAssertEqual(state.motionGravityZ, 0.05, accuracy: 0.0001) + XCTAssertEqual(state.motionRotationRateX, 0.3, accuracy: 0.0001) + XCTAssertEqual(state.motionRotationRateY, -0.1, accuracy: 0.0001) + XCTAssertEqual(state.motionRotationRateZ, 0.7, accuracy: 0.0001) + } + + // MARK: - State reset + + func test_psvr2State_reset_clearsAllFields() { + var state = PSVR2SenseControllerState() + state.isConnected = true + state.createButtonPressed = true + state.homeButtonPressed = true + state.touchpadButtonPressed = true + state.touchpadX = 0.5 + state.touchpadY = -0.3 + state.touchpadTouched = true + state.leftAdaptiveTriggerValue = 0.8 + state.rightAdaptiveTriggerValue = 0.6 + state.motionGravityX = 1.0 + state.motionGravityY = -9.8 + state.motionGravityZ = 0.2 + state.motionRotationRateX = 0.4 + state.motionRotationRateY = 0.1 + state.motionRotationRateZ = -0.3 + + state = PSVR2SenseControllerState() + + XCTAssertFalse(state.isConnected) + XCTAssertFalse(state.createButtonPressed) + XCTAssertFalse(state.homeButtonPressed) + XCTAssertFalse(state.touchpadButtonPressed) + XCTAssertEqual(state.touchpadX, 0) + XCTAssertEqual(state.touchpadY, 0) + XCTAssertFalse(state.touchpadTouched) + XCTAssertEqual(state.leftAdaptiveTriggerValue, 0) + XCTAssertEqual(state.rightAdaptiveTriggerValue, 0) + XCTAssertEqual(state.motionGravityX, 0) + XCTAssertEqual(state.motionGravityY, 0) + XCTAssertEqual(state.motionGravityZ, 0) + XCTAssertEqual(state.motionRotationRateX, 0) + XCTAssertEqual(state.motionRotationRateY, 0) + XCTAssertEqual(state.motionRotationRateZ, 0) + } + + // MARK: - Simulated connect / disconnect lifecycle + + func test_psvr2_simulatedConnect_setsIsConnectedAndState() { + let input = InputSystem.shared + + // Simulate what configurePSVR2IfNeeded would do on a real connect + input.psvr2SenseControllerState = PSVR2SenseControllerState() + input.psvr2SenseControllerState.isConnected = true + + XCTAssertTrue(input.psvr2SenseControllerState.isConnected) + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.homeButtonPressed) + } + + func test_psvr2_simulatedDisconnect_resetsAllState() { + let input = InputSystem.shared + + // Simulate a live session with some active state + input.psvr2SenseControllerState.isConnected = true + input.psvr2SenseControllerState.createButtonPressed = true + input.psvr2SenseControllerState.touchpadX = 0.4 + input.psvr2SenseControllerState.leftAdaptiveTriggerValue = 0.7 + input.psvr2SenseControllerState.motionGravityY = -9.8 + + // Simulate what clearPSVR2IfNeeded does when the controller disconnects + input.psvr2SenseControllerState = PSVR2SenseControllerState() + + XCTAssertFalse(input.psvr2SenseControllerState.isConnected) + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, 0) + XCTAssertEqual(input.psvr2SenseControllerState.leftAdaptiveTriggerValue, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, 0) + } + + func test_psvr2MotionEnabled_canBeToggled() { + let input = InputSystem.shared + + XCTAssertFalse(input.psvr2MotionEnabled) + + input.psvr2MotionEnabled = true + XCTAssertTrue(input.psvr2MotionEnabled) + + input.psvr2MotionEnabled = false + XCTAssertFalse(input.psvr2MotionEnabled) + } + + // MARK: - M2: Button & touchpad handler logic + + func test_applyTouchpadSurface_touchedWhenXAboveEpsilon() { + let input = InputSystem.shared + input.applyTouchpadSurface(x: 0.5, y: 0.0) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadTouched) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, 0.5, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadY, 0.0, accuracy: 0.0001) + } + + func test_applyTouchpadSurface_touchedWhenYAboveEpsilon() { + let input = InputSystem.shared + input.applyTouchpadSurface(x: 0.0, y: -0.8) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadTouched) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, 0.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadY, -0.8, accuracy: 0.0001) + } + + func test_applyTouchpadSurface_notTouchedAtCenter() { + let input = InputSystem.shared + input.applyTouchpadSurface(x: 0.0, y: 0.0) + XCTAssertFalse(input.psvr2SenseControllerState.touchpadTouched) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, 0.0) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadY, 0.0) + } + + func test_applyTouchpadSurface_notTouchedBelowEpsilon() { + let input = InputSystem.shared + // Values below psvr2TouchpadEpsilon (0.01) must not set touchedTouched + input.applyTouchpadSurface(x: 0.005, y: 0.005) + XCTAssertFalse(input.psvr2SenseControllerState.touchpadTouched) + } + + func test_applyTouchpadSurface_clearsWhenReturnsToCenter() { + let input = InputSystem.shared + input.applyTouchpadSurface(x: 0.6, y: 0.3) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadTouched) + + input.applyTouchpadSurface(x: 0.0, y: 0.0) + XCTAssertFalse(input.psvr2SenseControllerState.touchpadTouched) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, 0.0) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadY, 0.0) + } + + func test_applyTouchpadSurface_extremeCorners() { + let input = InputSystem.shared + + input.applyTouchpadSurface(x: 1.0, y: 1.0) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, 1.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadY, 1.0, accuracy: 0.0001) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadTouched) + + input.applyTouchpadSurface(x: -1.0, y: -1.0) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadX, -1.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.touchpadY, -1.0, accuracy: 0.0001) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadTouched) + } + + func test_psvr2_createButton_pressReleaseCycle() { + let input = InputSystem.shared + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + + // Simulate handler firing on press + input.psvr2SenseControllerState.createButtonPressed = true + XCTAssertTrue(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.homeButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.touchpadButtonPressed) + + // Simulate handler firing on release + input.psvr2SenseControllerState.createButtonPressed = false + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + } + + func test_psvr2_homeButton_pressReleaseCycle() { + let input = InputSystem.shared + XCTAssertFalse(input.psvr2SenseControllerState.homeButtonPressed) + + input.psvr2SenseControllerState.homeButtonPressed = true + XCTAssertTrue(input.psvr2SenseControllerState.homeButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.touchpadButtonPressed) + + input.psvr2SenseControllerState.homeButtonPressed = false + XCTAssertFalse(input.psvr2SenseControllerState.homeButtonPressed) + } + + func test_psvr2_touchpadButton_pressReleaseCycle() { + let input = InputSystem.shared + XCTAssertFalse(input.psvr2SenseControllerState.touchpadButtonPressed) + + input.psvr2SenseControllerState.touchpadButtonPressed = true + XCTAssertTrue(input.psvr2SenseControllerState.touchpadButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.homeButtonPressed) + + input.psvr2SenseControllerState.touchpadButtonPressed = false + XCTAssertFalse(input.psvr2SenseControllerState.touchpadButtonPressed) + } + + func test_psvr2_buttons_doNotCrossContaminate() { + let input = InputSystem.shared + + input.psvr2SenseControllerState.createButtonPressed = true + input.psvr2SenseControllerState.homeButtonPressed = true + input.psvr2SenseControllerState.touchpadButtonPressed = true + + // Releasing one must not clear the others + input.psvr2SenseControllerState.createButtonPressed = false + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertTrue(input.psvr2SenseControllerState.homeButtonPressed) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadButtonPressed) + + input.psvr2SenseControllerState.homeButtonPressed = false + XCTAssertFalse(input.psvr2SenseControllerState.createButtonPressed) + XCTAssertFalse(input.psvr2SenseControllerState.homeButtonPressed) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadButtonPressed) + } + + func test_applyTouchpadSurface_doesNotAffectButtons() { + let input = InputSystem.shared + input.psvr2SenseControllerState.touchpadButtonPressed = true + + input.applyTouchpadSurface(x: 0.5, y: 0.3) + + // Surface update must not clear the click state + XCTAssertTrue(input.psvr2SenseControllerState.touchpadButtonPressed) + XCTAssertTrue(input.psvr2SenseControllerState.touchpadTouched) + } + + // MARK: - M3: PSVR2TriggerEffect enum + + func test_psvr2TriggerEffect_offEquality() { + XCTAssertEqual(PSVR2TriggerEffect.off, PSVR2TriggerEffect.off) + } + + func test_psvr2TriggerEffect_feedbackEquality() { + let a = PSVR2TriggerEffect.feedback(startPosition: 0.2, strength: 0.8) + let b = PSVR2TriggerEffect.feedback(startPosition: 0.2, strength: 0.8) + let c = PSVR2TriggerEffect.feedback(startPosition: 0.5, strength: 0.8) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + func test_psvr2TriggerEffect_weaponEquality() { + let a = PSVR2TriggerEffect.weapon(startPosition: 0.1, endPosition: 0.9, strength: 1.0) + let b = PSVR2TriggerEffect.weapon(startPosition: 0.1, endPosition: 0.9, strength: 1.0) + let c = PSVR2TriggerEffect.weapon(startPosition: 0.2, endPosition: 0.9, strength: 1.0) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + func test_psvr2TriggerEffect_vibrationEquality() { + let a = PSVR2TriggerEffect.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 10.0) + let b = PSVR2TriggerEffect.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 10.0) + let c = PSVR2TriggerEffect.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 20.0) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + func test_psvr2TriggerEffect_slopeFeedbackEquality() { + let a = PSVR2TriggerEffect.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 0.8) + let b = PSVR2TriggerEffect.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 0.8) + let c = PSVR2TriggerEffect.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.3, endStrength: 0.8) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + func test_psvr2TriggerEffect_differentCasesNotEqual() { + XCTAssertNotEqual(PSVR2TriggerEffect.off, PSVR2TriggerEffect.feedback(startPosition: 0, strength: 0)) + XCTAssertNotEqual( + PSVR2TriggerEffect.feedback(startPosition: 0, strength: 0), + PSVR2TriggerEffect.weapon(startPosition: 0, endPosition: 1, strength: 0) + ) + } + + // MARK: - M3: setLeftTriggerEffect / setRightTriggerEffect when disconnected + + func test_setLeftTriggerEffect_noCrashWhenDisconnected() { + let input = InputSystem.shared + // psvr2LeftTrigger is nil (no hardware); calling must not crash + input.setLeftTriggerEffect(.off) + input.setLeftTriggerEffect(.feedback(startPosition: 0.3, strength: 0.7)) + input.setLeftTriggerEffect(.weapon(startPosition: 0.2, endPosition: 0.8, strength: 1.0)) + input.setLeftTriggerEffect(.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 15.0)) + input.setLeftTriggerEffect(.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 1.0)) + } + + func test_setRightTriggerEffect_noCrashWhenDisconnected() { + let input = InputSystem.shared + // psvr2RightTrigger is nil (no hardware); calling must not crash + input.setRightTriggerEffect(.off) + input.setRightTriggerEffect(.feedback(startPosition: 0.3, strength: 0.7)) + input.setRightTriggerEffect(.weapon(startPosition: 0.2, endPosition: 0.8, strength: 1.0)) + input.setRightTriggerEffect(.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 15.0)) + input.setRightTriggerEffect(.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 1.0)) + } + + // MARK: - M3: Adaptive trigger value state fields + + func test_psvr2_adaptiveTriggerValues_defaultToZero() { + let input = InputSystem.shared + XCTAssertEqual(input.psvr2SenseControllerState.leftAdaptiveTriggerValue, 0) + XCTAssertEqual(input.psvr2SenseControllerState.rightAdaptiveTriggerValue, 0) + } + + func test_psvr2_adaptiveTriggerValues_trackIndependentlyOnSystem() { + let input = InputSystem.shared + + input.psvr2SenseControllerState.leftAdaptiveTriggerValue = 0.6 + input.psvr2SenseControllerState.rightAdaptiveTriggerValue = 0.2 + + XCTAssertEqual(input.psvr2SenseControllerState.leftAdaptiveTriggerValue, 0.6, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.rightAdaptiveTriggerValue, 0.2, accuracy: 0.0001) + + // Updating left must not disturb right + input.psvr2SenseControllerState.leftAdaptiveTriggerValue = 1.0 + XCTAssertEqual(input.psvr2SenseControllerState.leftAdaptiveTriggerValue, 1.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.rightAdaptiveTriggerValue, 0.2, accuracy: 0.0001) + } + + func test_psvr2_adaptiveTriggerValues_resetOnDisconnect() { + let input = InputSystem.shared + input.psvr2SenseControllerState.leftAdaptiveTriggerValue = 0.9 + input.psvr2SenseControllerState.rightAdaptiveTriggerValue = 0.7 + + // Simulate disconnect + input.psvr2SenseControllerState = PSVR2SenseControllerState() + + XCTAssertEqual(input.psvr2SenseControllerState.leftAdaptiveTriggerValue, 0) + XCTAssertEqual(input.psvr2SenseControllerState.rightAdaptiveTriggerValue, 0) + } + + // MARK: - M4: Motion sensing + + func test_applyMotionData_updatesAllFieldsWhenEnabled() { + let input = InputSystem.shared + input.psvr2MotionEnabled = true + + input.applyMotionData( + gravityX: 0.1, gravityY: -9.8, gravityZ: 0.05, + rotationRateX: 0.3, rotationRateY: -0.1, rotationRateZ: 0.7 + ) + + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityX, 0.1, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, -9.8, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityZ, 0.05, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateX, 0.3, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateY, -0.1, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateZ, 0.7, accuracy: 0.0001) + } + + func test_applyMotionData_ignoredWhenDisabled() { + let input = InputSystem.shared + input.psvr2MotionEnabled = false + + input.applyMotionData( + gravityX: 1.0, gravityY: -9.8, gravityZ: 0.5, + rotationRateX: 0.4, rotationRateY: 0.2, rotationRateZ: -0.3 + ) + + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityX, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityZ, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateX, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateY, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateZ, 0) + } + + func test_applyMotionData_toggleAtRuntime() { + let input = InputSystem.shared + input.psvr2MotionEnabled = true + + input.applyMotionData( + gravityX: 0.2, gravityY: -9.5, gravityZ: 0.1, + rotationRateX: 0.0, rotationRateY: 0.0, rotationRateZ: 0.0 + ) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, -9.5, accuracy: 0.0001) + + // Disable mid-session — subsequent data must be ignored + input.psvr2MotionEnabled = false + input.applyMotionData( + gravityX: 99.0, gravityY: 99.0, gravityZ: 99.0, + rotationRateX: 99.0, rotationRateY: 99.0, rotationRateZ: 99.0 + ) + + // Fields must retain the last value written while enabled + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityX, 0.2, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, -9.5, accuracy: 0.0001) + XCTAssertNotEqual(input.psvr2SenseControllerState.motionGravityX, 99.0) + } + + func test_applyMotionData_gravityAndRotationRateIndependent() { + let input = InputSystem.shared + input.psvr2MotionEnabled = true + + input.applyMotionData( + gravityX: 1.0, gravityY: 2.0, gravityZ: 3.0, + rotationRateX: 4.0, rotationRateY: 5.0, rotationRateZ: 6.0 + ) + + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityX, 1.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, 2.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityZ, 3.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateX, 4.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateY, 5.0, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateZ, 6.0, accuracy: 0.0001) + } + + func test_applyMotionData_resetOnDisconnect() { + let input = InputSystem.shared + input.psvr2MotionEnabled = true + input.applyMotionData( + gravityX: 0.5, gravityY: -9.8, gravityZ: 0.2, + rotationRateX: 1.0, rotationRateY: -0.5, rotationRateZ: 0.3 + ) + + // Simulate disconnect + input.psvr2SenseControllerState = PSVR2SenseControllerState() + + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityX, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityZ, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateX, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateY, 0) + XCTAssertEqual(input.psvr2SenseControllerState.motionRotationRateZ, 0) + } + + // MARK: - M5: Public API surface + + func test_setInput_psvr2_motionEnabled_true() { + let input = InputSystem.shared + input.psvr2MotionEnabled = false + + setInput(.psvr2(.motionEnabled(true))) + + XCTAssertTrue(input.psvr2MotionEnabled) + } + + func test_setInput_psvr2_motionEnabled_false() { + let input = InputSystem.shared + input.psvr2MotionEnabled = true + + setInput(.psvr2(.motionEnabled(false))) + + XCTAssertFalse(input.psvr2MotionEnabled) + } + + func test_setInput_psvr2_leftTriggerEffect_noCrashWhenDisconnected() { + // psvr2LeftTrigger is nil; calling via the public API must not crash + setInput(.psvr2(.leftTriggerEffect(.off))) + setInput(.psvr2(.leftTriggerEffect(.feedback(startPosition: 0.2, strength: 0.8)))) + setInput(.psvr2(.leftTriggerEffect(.weapon(startPosition: 0.1, endPosition: 0.9, strength: 1.0)))) + setInput(.psvr2(.leftTriggerEffect(.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 20.0)))) + setInput(.psvr2(.leftTriggerEffect(.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 1.0)))) + } + + func test_setInput_psvr2_rightTriggerEffect_noCrashWhenDisconnected() { + setInput(.psvr2(.rightTriggerEffect(.off))) + setInput(.psvr2(.rightTriggerEffect(.feedback(startPosition: 0.3, strength: 0.6)))) + } + + func test_getPSVR2SenseState_returnsCurrentState() { + let input = InputSystem.shared + input.psvr2SenseControllerState.touchpadX = 0.42 + input.psvr2SenseControllerState.createButtonPressed = true + + let state = getPSVR2SenseState() + + XCTAssertEqual(state.touchpadX, 0.42, accuracy: 0.0001) + XCTAssertTrue(state.createButtonPressed) + } + + func test_isPSVR2SenseConnected_reflectsState() { + let input = InputSystem.shared + + input.psvr2SenseControllerState.isConnected = false + XCTAssertFalse(isPSVR2SenseConnected()) + + input.psvr2SenseControllerState.isConnected = true + XCTAssertTrue(isPSVR2SenseConnected()) + } + + // MARK: - M6: Integration and edge cases + + func test_setInput_motionEnabled_integratedWithApplyMotionData() { + let input = InputSystem.shared + + // Enable via public API, then verify applyMotionData writes through + setInput(.psvr2(.motionEnabled(true))) + input.applyMotionData(gravityX: 0.1, gravityY: -9.8, gravityZ: 0.0, + rotationRateX: 0.2, rotationRateY: 0.0, rotationRateZ: -0.1) + + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, -9.8, accuracy: 0.0001) + + // Disable via public API, then verify applyMotionData is blocked + setInput(.psvr2(.motionEnabled(false))) + input.applyMotionData(gravityX: 99.0, gravityY: 99.0, gravityZ: 99.0, + rotationRateX: 99.0, rotationRateY: 99.0, rotationRateZ: 99.0) + + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, -9.8, accuracy: 0.0001) + } + + func test_getPSVR2SenseState_returnsValueSnapshot() { + let input = InputSystem.shared + input.psvr2SenseControllerState.touchpadX = 0.3 + input.psvr2SenseControllerState.createButtonPressed = true + + let snapshot = getPSVR2SenseState() + + // Mutate live state after taking the snapshot + input.psvr2SenseControllerState.touchpadX = 0.9 + input.psvr2SenseControllerState.createButtonPressed = false + + // Snapshot must be unaffected (struct copy semantics) + XCTAssertEqual(snapshot.touchpadX, 0.3, accuracy: 0.0001) + XCTAssertTrue(snapshot.createButtonPressed) + } + + func test_psvr2_motionAndTriggerFields_doNotContaminate() { + let input = InputSystem.shared + input.psvr2MotionEnabled = true + + input.psvr2SenseControllerState.leftAdaptiveTriggerValue = 0.75 + input.psvr2SenseControllerState.rightAdaptiveTriggerValue = 0.5 + + input.applyMotionData(gravityX: 1.0, gravityY: -9.8, gravityZ: 0.5, + rotationRateX: 0.3, rotationRateY: 0.1, rotationRateZ: -0.2) + + // Motion update must not disturb trigger values + XCTAssertEqual(input.psvr2SenseControllerState.leftAdaptiveTriggerValue, 0.75, accuracy: 0.0001) + XCTAssertEqual(input.psvr2SenseControllerState.rightAdaptiveTriggerValue, 0.5, accuracy: 0.0001) + + // Trigger update must not disturb motion values + input.psvr2SenseControllerState.leftAdaptiveTriggerValue = 1.0 + XCTAssertEqual(input.psvr2SenseControllerState.motionGravityY, -9.8, accuracy: 0.0001) + } +} diff --git a/Tests/UntoldEngineTests/RenderExtensionShaderSupportTests.swift b/Tests/UntoldEngineTests/RenderExtensionShaderSupportTests.swift new file mode 100644 index 00000000..fa298e10 --- /dev/null +++ b/Tests/UntoldEngineTests/RenderExtensionShaderSupportTests.swift @@ -0,0 +1,70 @@ +// +// RenderExtensionShaderSupportTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import UntoldEngine +import UntoldEngineShaderSupport +import XCTest + +final class RenderExtensionShaderSupportTests: XCTestCase { + func testModelSurfaceExtensionArgumentBufferABI() { + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentBufferIndex.rawValue, 10) + + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentTexture0.rawValue, 0) + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentTexture7.rawValue, 7) + + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentSampler0.rawValue, 8) + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentSampler7.rawValue, 15) + + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentBuffer0.rawValue, 16) + XCTAssertEqual(UntoldModelSurfaceExtensionArgumentBuffer15.rawValue, 31) + } + + func testSwiftModelSurfaceExtensionArgumentConstantsMatchShaderSupport() { + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.argumentBufferIndex, + Int(UntoldModelSurfaceExtensionArgumentBufferIndex.rawValue) + ) + + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.texture0, + Int(UntoldModelSurfaceExtensionArgumentTexture0.rawValue) + ) + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.texture7, + Int(UntoldModelSurfaceExtensionArgumentTexture7.rawValue) + ) + + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.sampler0, + Int(UntoldModelSurfaceExtensionArgumentSampler0.rawValue) + ) + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.sampler7, + Int(UntoldModelSurfaceExtensionArgumentSampler7.rawValue) + ) + + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.buffer0, + Int(UntoldModelSurfaceExtensionArgumentBuffer0.rawValue) + ) + XCTAssertEqual( + RenderExtensionModelSurfaceArgument.buffer15, + Int(UntoldModelSurfaceExtensionArgumentBuffer15.rawValue) + ) + } + + func testLegacyModelSurfaceExtensionSlotsRemainStableDuringMigration() { + XCTAssertEqual(UntoldModelSurfaceExtensionFragmentBuffer0.rawValue, 10) + XCTAssertEqual(UntoldModelSurfaceExtensionFragmentBuffer3.rawValue, 13) + + XCTAssertEqual(UntoldModelSurfaceExtensionFragmentTexture0.rawValue, 10) + XCTAssertEqual(UntoldModelSurfaceExtensionFragmentTexture3.rawValue, 13) + } +} diff --git a/Tests/UntoldEngineTests/TestEngineReset.swift b/Tests/UntoldEngineTests/TestEngineReset.swift index b899cfc6..780a25b9 100644 --- a/Tests/UntoldEngineTests/TestEngineReset.swift +++ b/Tests/UntoldEngineTests/TestEngineReset.swift @@ -27,6 +27,7 @@ needsFinalizeDestroys = false hasPendingDestroys = false clearCustomSystems() + RenderExtensionRegistry.shared.removeAll() for frame in 0 ..< 3 { tripleVisibleEntities.setWrite(frame: frame, with: []) } diff --git a/Tools/UntoldEngineCLI/MULTI_PLATFORM_USAGE.md b/Tools/UntoldEngineCLI/MULTI_PLATFORM_USAGE.md index bc1b5819..28c9129f 100644 --- a/Tools/UntoldEngineCLI/MULTI_PLATFORM_USAGE.md +++ b/Tools/UntoldEngineCLI/MULTI_PLATFORM_USAGE.md @@ -8,7 +8,7 @@ Create a multi-platform project in one command: ```bash mkdir MyGame && cd MyGame -untoldengine-create create MyGame --platform multi --team-id YOUR_TEAM_ID +untoldengine-create MyGame --platform multi --team-id YOUR_TEAM_ID ``` This creates a single Xcode project that can be built for macOS, iOS, and visionOS. @@ -18,12 +18,12 @@ This creates a single Xcode project that can be built for macOS, iOS, and vision ### Basic Multi-Platform Project ```bash -untoldengine-create create --platform multi +untoldengine-create --platform multi ``` **Required for iOS/visionOS builds:** ```bash -untoldengine-create create MyGame --platform multi --team-id ABCD1234EF +untoldengine-create MyGame --platform multi --team-id ABCD1234EF ``` The `--team-id` is your Apple Developer Team ID, required for code signing iOS and visionOS apps. @@ -33,7 +33,7 @@ The `--team-id` is your Apple Developer Team ID, required for code signing iOS a Specify deployment versions for each platform: ```bash -untoldengine-create create MyGame \ +untoldengine-create MyGame \ --platform multi \ --macos-version 14 \ --ios-version 17 \ @@ -49,7 +49,7 @@ untoldengine-create create MyGame \ ### Full Options ```bash -untoldengine-create create MyGame \ +untoldengine-create MyGame \ --platform multi \ --bundle-id com.mycompany.mygame \ --output ~/Projects \ @@ -135,9 +135,9 @@ Press `Cmd+R` or click the Play button to build and run for the selected platfor ```bash # Create three separate projects -untoldengine-create create MyGame --platform macos -untoldengine-create create MyGame --platform ios -untoldengine-create create MyGame --platform visionos +untoldengine-create MyGame --platform macos +untoldengine-create MyGame --platform ios +untoldengine-create MyGame --platform visionos ``` **Result:** 3 separate Xcode projects to maintain @@ -146,7 +146,7 @@ untoldengine-create create MyGame --platform visionos ```bash # Create one unified project -untoldengine-create create MyGame --platform multi --team-id YOUR_TEAM_ID +untoldengine-create MyGame --platform multi --team-id YOUR_TEAM_ID ``` **Result:** 1 Xcode project that targets all platforms @@ -231,7 +231,7 @@ Your Team ID is required for iOS and visionOS code signing. Find it: ```bash mkdir SpaceGame && cd SpaceGame -untoldengine-create create SpaceGame \ +untoldengine-create SpaceGame \ --platform multi \ --bundle-id com.studio.spacegame \ --team-id ABCD1234EF @@ -241,7 +241,7 @@ untoldengine-create create SpaceGame \ ```bash mkdir ReleaseGame && cd ReleaseGame -untoldengine-create create ReleaseGame \ +untoldengine-create ReleaseGame \ --platform multi \ --bundle-id com.studio.releasegame \ --team-id ABCD1234EF \ @@ -253,7 +253,7 @@ untoldengine-create create ReleaseGame \ ```bash mkdir ModernGame && cd ModernGame -untoldengine-create create ModernGame \ +untoldengine-create ModernGame \ --platform multi \ --macos-version 15 \ --ios-version 18 \ diff --git a/Tools/UntoldEngineCLI/README.md b/Tools/UntoldEngineCLI/README.md index ac4987ed..b27961c0 100644 --- a/Tools/UntoldEngineCLI/README.md +++ b/Tools/UntoldEngineCLI/README.md @@ -54,25 +54,25 @@ untoldengine-create --help # Create a macOS project cd ~/anywhere mkdir MyGame && cd MyGame -untoldengine-create create MyGame +untoldengine-create MyGame # Create an iOS project mkdir MobileGame && cd MobileGame -untoldengine-create create MobileGame --platform ios --bundle-id com.company.game +untoldengine-create MobileGame --platform ios --bundle-id com.company.game # Create a visionOS project -untoldengine-create create VRGame --platform visionos +untoldengine-create VRGame --platform visionos # Create a multi-platform project (macOS, iOS, visionOS) mkdir CrossPlatformGame && cd CrossPlatformGame -untoldengine-create create CrossPlatformGame --platform multi --team-id YOUR_TEAM_ID +untoldengine-create CrossPlatformGame --platform multi --team-id YOUR_TEAM_ID ``` ## Commands -### `create` - Create a new project +### Project creation (root command) -Creates a new UntoldEngine game project with the specified configuration. +Pass the project name as the first argument to create a new UntoldEngine game project. **Arguments:** - `projectName` - Name of the project to create @@ -93,18 +93,18 @@ Creates a new UntoldEngine game project with the specified configuration. ```bash # Create macOS project in current directory mkdir MyGame && cd MyGame -untoldengine-create create MyGame +untoldengine-create MyGame # Create iOS AR project mkdir ARGame && cd ARGame -untoldengine-create create ARGame --platform ios-ar --bundle-id com.company.argame +untoldengine-create ARGame --platform ios-ar --bundle-id com.company.argame # Create visionOS project with custom output -untoldengine-create create VRGame --platform visionos --output ~/Projects +untoldengine-create VRGame --platform visionos --output ~/Projects # Create multi-platform project mkdir MultiGame && cd MultiGame -untoldengine-create create MultiGame --platform multi --team-id ABCD1234EF +untoldengine-create MultiGame --platform multi --team-id ABCD1234EF ``` ### `update` - Update an existing project diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CreateCommand.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CreateCommand.swift deleted file mode 100644 index 4b09664a..00000000 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/CreateCommand.swift +++ /dev/null @@ -1,325 +0,0 @@ -// -// CreateCommand.swift -// UntoldEngine -// -// Copyright (C) Untold Engine Studios -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/. - -// -// CreateCommand.swift -// UntoldEngineCLI -// -// Command for creating new UntoldEngine game projects -// - -import AppKit -import ArgumentParser -import Foundation -import UntoldEngine - -struct CreateCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create", - abstract: "Create a new UntoldEngine game project", - discussion: """ - Creates a new game project with the specified platform and configuration. - The project structure will be created in the current directory. - - Examples: - # Create macOS project (in current directory) - mkdir MyGame && cd MyGame - untoldengine-create create MyGame - - # Create iOS project with custom bundle ID - mkdir MobileGame && cd MobileGame - untoldengine-create create MobileGame --platform ios --bundle-id com.company.game - - # Create visionOS project with custom output location - untoldengine-create create VRGame --platform visionos --output ~/Projects - - # Create multi-platform project (macOS, iOS, visionOS) - mkdir CrossPlatformGame && cd CrossPlatformGame - untoldengine-create create CrossPlatformGame --platform multi --team-id YOUR_TEAM_ID - - # Create multi-platform with custom deployment targets - untoldengine-create create MyGame --platform multi --macos-version 14 --ios-version 17 --visionos-version 2 - """ - ) - - // MARK: - Arguments - - @Argument(help: "Name of the project to create") - var projectName: String - - // MARK: - Options - - @Option(name: .shortAndLong, help: "Target platform (macos, ios, ios-ar, visionos, multi)") - var platform: String = "macos" - - @Option(name: .long, help: "Bundle identifier (e.g., com.company.game)") - var bundleId: String? - - @Option(name: .shortAndLong, help: "Output directory (default: current directory)") - var output: String? - - @Option(name: .long, help: "macOS deployment version (13, 14, 15)") - var macosVersion: String = "15" - - @Option(name: .long, help: "iOS deployment version (16, 17, 18)") - var iosVersion: String = "17" - - @Option(name: .long, help: "visionOS deployment version (1, 2)") - var visionosVersion: String = "2" - - @Option(name: .long, help: "Apple Developer Team ID for code signing") - var teamId: String? - - @Option(name: .long, help: "Optimization level (none, speed, size)") - var optimization: String = "none" - - @Flag(name: .long, inversion: .prefixedNo, help: "Include debug information") - var debug: Bool = true - - // MARK: - Execution - - func run() async throws { - printInfo("🎮 UntoldEngine Project Creator") - printInfo("Creating project: \(projectName)") - print("") - - // 1. Resolve output directory (default: current directory) - let outputURL = resolveOutputDirectory() - - // 2. Generate and validate bundle identifier - let finalBundleId = bundleId ?? generateBundleIdentifier(projectName: projectName) - guard validateBundleIdentifier(finalBundleId) else { - throw CLIError.invalidBundleIdentifier(finalBundleId) - } - - // 3. Create build settings - let settings = try createBuildSettings( - outputURL: outputURL, - bundleId: finalBundleId - ) - - // 4. Calculate where GameData will be created by BuildSystem - let projectDir = outputURL.appendingPathComponent(projectName) - let gameDataPath = projectDir - .appendingPathComponent("Sources") - .appendingPathComponent(projectName) - .appendingPathComponent("GameData") - - // 5. Set assetBasePath to the GameData location - // BuildSystem will create the folder structure here when it bundles assets - assetBasePath = gameDataPath - - // 6. Show configuration summary - printConfigurationSummary(settings: settings, projectDir: projectDir, gameDataPath: gameDataPath) - - // 7. Run build - try await runBuild(settings: settings, gameDataPath: gameDataPath) - } - - // MARK: - Helper Methods - - private func resolveOutputDirectory() -> URL { - if let outputStr = output { - return resolvePath(outputStr) - } - - // Default: current directory. - // If the user did `mkdir MyGame && cd MyGame && untoldengine-create create MyGame`, - // the current dir is already named after the project. BuildSystem will append - // projectName again, so pass the parent to avoid double-nesting. - let currentDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - if currentDir.lastPathComponent == projectName { - return currentDir.deletingLastPathComponent() - } - return currentDir - } - - private func createBuildSettings(outputURL: URL, bundleId: String) throws -> BuildSettings { - // Parse platform - let buildTarget: BuildTarget - var isIOSAR = false - - switch platform.lowercased() { - case "macos": - let version = parseMacOSVersion(macosVersion) - buildTarget = .macOS(deployment: version) - - case "ios": - let version = parseIOSVersion(iosVersion) - buildTarget = .iOS(deployment: version) - - case "ios-ar": - let version = parseIOSVersion(iosVersion) - buildTarget = .iOS(deployment: version) - isIOSAR = true - - case "visionos": - let version = parseVisionOSVersion(visionosVersion) - buildTarget = .visionOS(deployment: version) - - case "multi": - let macOS = parseMacOSVersion(macosVersion) - let iOS = parseIOSVersion(iosVersion) - let visionOS = parseVisionOSVersion(visionosVersion) - buildTarget = .multi(macOS: macOS, iOS: iOS, visionOS: visionOS) - - default: - throw CLIError.invalidPlatform(platform) - } - - // Parse optimization - let optimizationLevel: OptimizationLevel - switch optimization.lowercased() { - case "none": - optimizationLevel = .none - case "speed": - optimizationLevel = .speed - case "size": - optimizationLevel = .size - default: - throw CLIError.invalidOptimization(optimization) - } - - return BuildSettings( - projectName: projectName, - bundleIdentifier: bundleId, - outputPath: outputURL, - target: buildTarget, - scenes: [], - includeDebugInfo: debug, - optimizationLevel: optimizationLevel, - teamID: teamId, - isIOSAR: isIOSAR - ) - } - - private func parseMacOSVersion(_ version: String) -> MacOSVersion { - switch version { - case "13", "13.0": - return .v13 - case "14", "14.0": - return .v14 - case "15", "15.0": - return .v15 - default: - return .v15 - } - } - - private func parseIOSVersion(_ version: String) -> IOSVersion { - switch version { - case "16", "16.0": - return .v16 - case "17", "17.0": - return .v17 - case "18", "18.0": - return .v18 - default: - return .v17 - } - } - - private func parseVisionOSVersion(_ version: String) -> VisionOSVersion { - switch version { - case "1", "1.0": - return .v1 - case "2", "2.0": - return .v2 - case "26", "26.0": - return .v26 - default: - return .v26 - } - } - - private func printConfigurationSummary(settings: BuildSettings, projectDir: URL, gameDataPath: URL) { - print("📋 Configuration:") - print(" Project Name: \(settings.projectName)") - print(" Bundle ID: \(settings.bundleIdentifier)") - - // Handle multi-platform display differently - if case .multi = settings.target { - print(" Platform: Multi-Platform") - if let macOSVersion = settings.target.deploymentTarget(for: "macOS") { - print(" - macOS: \(macOSVersion)") - } - if let iOSVersion = settings.target.deploymentTarget(for: "iOS") { - print(" - iOS: \(iOSVersion)") - } - if let visionOSVersion = settings.target.deploymentTarget(for: "visionOS") { - print(" - visionOS: \(visionOSVersion)") - } - } else { - print(" Platform: \(settings.target.platformName) \(settings.target.deploymentTarget)") - } - - if settings.isIOSAR { - print(" AR Support: Enabled") - } - print(" Project Dir: \(projectDir.path)") - print(" GameData Path: \(gameDataPath.path)") - print(" Optimization: \(settings.optimizationLevel.rawValue)") - print(" Debug Info: \(settings.includeDebugInfo ? "Yes" : "No")") - if let teamID = settings.teamID { - print(" Team ID: \(teamID)") - } - print("") - } - - private func runBuild(settings: BuildSettings, gameDataPath: URL) async throws { - printInfo("🔨 Starting build process...") - print("") - - do { - let result = try await BuildSystem.shared.build(settings: settings) { progress in - printProgress(progress) - } - - print("") - printSuccess("Build completed in \(String(format: "%.2f", result.buildTime))s") - printSuccess("Project created at: \(result.xcodeProjectPath.deletingLastPathComponent().path)") - printInfo("Bundled \(result.bundledAssets.count) assets") - print("") - printInfo("📁 Add your game assets to: \(gameDataPath.path)") - print("") - - // Offer to open in Xcode - if confirm("Open project in Xcode?", default: true) { - NSWorkspace.shared.open(result.xcodeProjectPath) - } - - } catch { - print("") - throw CLIError.buildFailed(error.localizedDescription) - } - } -} - -// MARK: - CLI Errors - -enum CLIError: LocalizedError { - case invalidBundleIdentifier(String) - case invalidPlatform(String) - case invalidOptimization(String) - case buildFailed(String) - - var errorDescription: String? { - switch self { - case let .invalidBundleIdentifier(bundleId): - return "Invalid bundle identifier: \(bundleId). Use format: com.company.app" - case let .invalidPlatform(platform): - return "Invalid platform: \(platform). Valid options: macos, ios, ios-ar, visionos, multi" - case let .invalidOptimization(opt): - return "Invalid optimization: \(opt). Valid options: none, speed, size" - case let .buildFailed(message): - return "Build failed: \(message)" - } - } -} diff --git a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift index b01c0136..931b9be1 100644 --- a/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift +++ b/Tools/UntoldEngineCLI/Sources/UntoldEngineCLI/UntoldEngineCLI.swift @@ -15,16 +15,310 @@ // Command-line tool for creating UntoldEngine game projects // +import AppKit import ArgumentParser import Foundation +import UntoldEngine @main struct UntoldEngineCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "untoldengine-create", abstract: "Create game projects using UntoldEngine build templates", + discussion: """ + Creates a new game project with the specified platform and configuration. + The project structure will be created in the current directory. + + Examples: + # Create macOS project (in current directory) + mkdir MyGame && cd MyGame + untoldengine-create MyGame + + # Create iOS project with custom bundle ID + mkdir MobileGame && cd MobileGame + untoldengine-create MobileGame --platform ios --bundle-id com.company.game + + # Create visionOS project with custom output location + untoldengine-create VRGame --platform visionos --output ~/Projects + + # Create multi-platform project (macOS, iOS, visionOS) + mkdir CrossPlatformGame && cd CrossPlatformGame + untoldengine-create CrossPlatformGame --platform multi --team-id YOUR_TEAM_ID + + # Create multi-platform with custom deployment targets + untoldengine-create MyGame --platform multi --macos-version 14 --ios-version 17 --visionos-version 2 + """, version: "0.1.0", - subcommands: [CreateCommand.self, UpdateCommand.self], - defaultSubcommand: CreateCommand.self + subcommands: [UpdateCommand.self] ) + + // MARK: - Arguments + + @Argument(help: "Name of the project to create") + var projectName: String + + // MARK: - Options + + @Option(name: .shortAndLong, help: "Target platform (macos, ios, ios-ar, visionos, multi)") + var platform: String = "macos" + + @Option(name: .long, help: "Bundle identifier (e.g., com.company.game)") + var bundleId: String? + + @Option(name: .shortAndLong, help: "Output directory (default: current directory)") + var output: String? + + @Option(name: .long, help: "macOS deployment version (13, 14, 15)") + var macosVersion: String = "15" + + @Option(name: .long, help: "iOS deployment version (16, 17, 18)") + var iosVersion: String = "17" + + @Option(name: .long, help: "visionOS deployment version (1, 2)") + var visionosVersion: String = "2" + + @Option(name: .long, help: "Apple Developer Team ID for code signing") + var teamId: String? + + @Option(name: .long, help: "Optimization level (none, speed, size)") + var optimization: String = "none" + + @Flag(name: .long, inversion: .prefixedNo, help: "Include debug information") + var debug: Bool = true + + // MARK: - Execution + + func run() async throws { + printInfo("🎮 UntoldEngine Project Creator") + printInfo("Creating project: \(projectName)") + print("") + + // 1. Resolve output directory (default: current directory) + let outputURL = resolveOutputDirectory() + + // 2. Generate and validate bundle identifier + let finalBundleId = bundleId ?? generateBundleIdentifier(projectName: projectName) + guard validateBundleIdentifier(finalBundleId) else { + throw CLIError.invalidBundleIdentifier(finalBundleId) + } + + // 3. Create build settings + let settings = try createBuildSettings( + outputURL: outputURL, + bundleId: finalBundleId + ) + + // 4. Calculate where GameData will be created by BuildSystem + let projectDir = outputURL.appendingPathComponent(projectName) + let gameDataPath = projectDir + .appendingPathComponent("Sources") + .appendingPathComponent(projectName) + .appendingPathComponent("GameData") + + // 5. Set assetBasePath to the GameData location + // BuildSystem will create the folder structure here when it bundles assets + assetBasePath = gameDataPath + + // 6. Show configuration summary + printConfigurationSummary(settings: settings, projectDir: projectDir, gameDataPath: gameDataPath) + + // 7. Run build + try await runBuild(settings: settings, gameDataPath: gameDataPath) + } + + // MARK: - Helper Methods + + private func resolveOutputDirectory() -> URL { + if let outputStr = output { + return resolvePath(outputStr) + } + + // Default: current directory. + // If the user did `mkdir MyGame && cd MyGame && untoldengine-create MyGame`, + // the current dir is already named after the project. BuildSystem will append + // projectName again, so pass the parent to avoid double-nesting. + let currentDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + if currentDir.lastPathComponent == projectName { + return currentDir.deletingLastPathComponent() + } + return currentDir + } + + private func createBuildSettings(outputURL: URL, bundleId: String) throws -> BuildSettings { + let buildTarget: BuildTarget + var isIOSAR = false + + switch platform.lowercased() { + case "macos": + let version = parseMacOSVersion(macosVersion) + buildTarget = .macOS(deployment: version) + + case "ios": + let version = parseIOSVersion(iosVersion) + buildTarget = .iOS(deployment: version) + + case "ios-ar": + let version = parseIOSVersion(iosVersion) + buildTarget = .iOS(deployment: version) + isIOSAR = true + + case "visionos": + let version = parseVisionOSVersion(visionosVersion) + buildTarget = .visionOS(deployment: version) + + case "multi": + let macOS = parseMacOSVersion(macosVersion) + let iOS = parseIOSVersion(iosVersion) + let visionOS = parseVisionOSVersion(visionosVersion) + buildTarget = .multi(macOS: macOS, iOS: iOS, visionOS: visionOS) + + default: + throw CLIError.invalidPlatform(platform) + } + + let optimizationLevel: OptimizationLevel + switch optimization.lowercased() { + case "none": + optimizationLevel = .none + case "speed": + optimizationLevel = .speed + case "size": + optimizationLevel = .size + default: + throw CLIError.invalidOptimization(optimization) + } + + return BuildSettings( + projectName: projectName, + bundleIdentifier: bundleId, + outputPath: outputURL, + target: buildTarget, + scenes: [], + includeDebugInfo: debug, + optimizationLevel: optimizationLevel, + teamID: teamId, + isIOSAR: isIOSAR + ) + } + + private func parseMacOSVersion(_ version: String) -> MacOSVersion { + switch version { + case "13", "13.0": + return .v13 + case "14", "14.0": + return .v14 + case "15", "15.0": + return .v15 + default: + return .v15 + } + } + + private func parseIOSVersion(_ version: String) -> IOSVersion { + switch version { + case "16", "16.0": + return .v16 + case "17", "17.0": + return .v17 + case "18", "18.0": + return .v18 + default: + return .v17 + } + } + + private func parseVisionOSVersion(_ version: String) -> VisionOSVersion { + switch version { + case "1", "1.0": + return .v1 + case "2", "2.0": + return .v2 + case "26", "26.0": + return .v26 + default: + return .v26 + } + } + + private func printConfigurationSummary(settings: BuildSettings, projectDir: URL, gameDataPath: URL) { + print("📋 Configuration:") + print(" Project Name: \(settings.projectName)") + print(" Bundle ID: \(settings.bundleIdentifier)") + + if case .multi = settings.target { + print(" Platform: Multi-Platform") + if let macOSVersion = settings.target.deploymentTarget(for: "macOS") { + print(" - macOS: \(macOSVersion)") + } + if let iOSVersion = settings.target.deploymentTarget(for: "iOS") { + print(" - iOS: \(iOSVersion)") + } + if let visionOSVersion = settings.target.deploymentTarget(for: "visionOS") { + print(" - visionOS: \(visionOSVersion)") + } + } else { + print(" Platform: \(settings.target.platformName) \(settings.target.deploymentTarget)") + } + + if settings.isIOSAR { + print(" AR Support: Enabled") + } + print(" Project Dir: \(projectDir.path)") + print(" GameData Path: \(gameDataPath.path)") + print(" Optimization: \(settings.optimizationLevel.rawValue)") + print(" Debug Info: \(settings.includeDebugInfo ? "Yes" : "No")") + if let teamID = settings.teamID { + print(" Team ID: \(teamID)") + } + print("") + } + + private func runBuild(settings: BuildSettings, gameDataPath: URL) async throws { + printInfo("🔨 Starting build process...") + print("") + + do { + let result = try await BuildSystem.shared.build(settings: settings) { progress in + printProgress(progress) + } + + print("") + printSuccess("Build completed in \(String(format: "%.2f", result.buildTime))s") + printSuccess("Project created at: \(result.xcodeProjectPath.deletingLastPathComponent().path)") + printInfo("Bundled \(result.bundledAssets.count) assets") + print("") + printInfo("📁 Add your game assets to: \(gameDataPath.path)") + print("") + + if confirm("Open project in Xcode?", default: true) { + NSWorkspace.shared.open(result.xcodeProjectPath) + } + + } catch { + print("") + throw CLIError.buildFailed(error.localizedDescription) + } + } +} + +// MARK: - CLI Errors + +enum CLIError: LocalizedError { + case invalidBundleIdentifier(String) + case invalidPlatform(String) + case invalidOptimization(String) + case buildFailed(String) + + var errorDescription: String? { + switch self { + case let .invalidBundleIdentifier(bundleId): + return "Invalid bundle identifier: \(bundleId). Use format: com.company.app" + case let .invalidPlatform(platform): + return "Invalid platform: \(platform). Valid options: macos, ios, ios-ar, visionos, multi" + case let .invalidOptimization(opt): + return "Invalid optimization: \(opt). Valid options: none, speed, size" + case let .buildFailed(message): + return "Build failed: \(message)" + } + } } diff --git a/docs/API/GettingStarted.md b/docs/API/GettingStarted.md index 44513a21..7331969b 100644 --- a/docs/API/GettingStarted.md +++ b/docs/API/GettingStarted.md @@ -4,14 +4,9 @@ This guide takes you from a fresh Untold Engine checkout to a working game project. You will run the demo, create an Xcode project, export assets into the engine's `.untold` runtime format, and load those assets from a `GameScene`. -You can create projects and export assets in two ways: +You can create projects using the Untold Engine Commannd Line Interface. -- Use the CLI if you prefer terminal commands or want a repeatable workflow. -- Use Untold Engine Studio if you prefer a visual editor for project setup, - asset import, and scene preparation. - -After your project is created, both workflows lead to the same place: an Xcode -project with a `GameData` folder that contains the assets your game loads at +After your project is created, an Xcode project with a `GameData` folder that contains the assets your game loads at runtime. --- @@ -33,31 +28,6 @@ swift run untolddemo ## Create an Xcode Project -You can create a project with either Untold Engine Studio or the CLI. If you are -new to Untold Engine, start with the Editor. If you prefer terminal workflows or -want repeatable project setup, use the CLI. - -### Option 1: Editor - -Use **Untold Engine Studio** for a visual workflow. It is a standalone editor for -creating projects, preparing assets, composing scenes, and generating scene files -used inside your game. - -[Download Untold Engine Studio](https://github.com/untoldengine/UntoldEditor/releases) - -![untoldeditor-image-1](../images/editor-highlight-1.png) - -To set up a project: -1. Click on "New". -2. Provide a Project name -3. Provide a Bundle name -4. Select the Target Platform -5. Provide an output path - -Untold Engine Studio will create an Xcode project ready to be used with Untold Engine. - -### Option 2: CLI - Use `untoldengine-create` to generate a ready-to-run Xcode project with Untold Engine wired in. Install it from the repository: @@ -73,7 +43,7 @@ create a Vision Pro project. ```bash cd ~/Projects -untoldengine-create create VisionGame --platform visionos +untoldengine-create VisionGame --platform visionos open VisionGame/VisionGame.xcodeproj ``` @@ -83,16 +53,16 @@ If you want to create a project for other platforms, you can use the flags below ```bash # visionOS (Apple Vision Pro) -untoldengine-create create MyGame --platform visionos +untoldengine-create MyGame --platform visionos # macOS (default) -untoldengine-create create MyGame --platform macos +untoldengine-create MyGame --platform macos # iOS with ARKit -untoldengine-create create MyGame --platform ios-ar +untoldengine-create MyGame --platform ios-ar # iOS -untoldengine-create create MyGame --platform ios +untoldengine-create MyGame --platform ios ``` Dependency behavior by platform: @@ -117,13 +87,7 @@ You can convert assets with either the Untold Engine Blender addon or the CLI. To convert a USDZ file into the `.untold` format using the add-on, follow the directions in [Using Blender Addon](UsingBlenderAddon.md). -After the model has been converted to `.untold` format: - -1. Click on "Import" in the Asset Browser View in the Editor. -2. Click on "Import Models" -3. When the import has completed, you will see your new `.untold` model under the Model Category. - -At this point, head over to your Xcode project. You will also notice that your `.untold` model is under `Sources//GameData/Models`. +After the model has been converted to `.untold` format, copy it into your Xcode project under `Sources//GameData/Models//`. ### Option 2: CLI @@ -196,34 +160,6 @@ once the mesh is parsed and uploaded to GPU memory. --- -## Loading an Untold Scene File - -Untold Engine Studio can save a composed scene as a `.untoldscene` file. A saved -scene can include model placement, light properties, post-processing settings, -and other scene data configured in the editor. - -Use `loadUntoldScene` to load that scene in your Xcode project. Place the -`.untoldscene` file in `Sources//GameData/Scenes`, then pass the -scene name without an extension, or with the `.untoldscene` extension. - -```swift - -//...After configureEngineSystems() - -loadUntoldScene(named: "LevelOne") { success in - if success { - moveCameraTo(entityId: findGameCamera(), 0.0, 3.0, 10.0) - ambientIntensity = 0.4 - } - setSceneReady(success) -} -``` - -By default, scene meshes load asynchronously. For tests or tools that need a -blocking load, pass `meshLoadingMode: .sync`. - ---- - ## Loading a Streamed Scene Use `setEntityStreamScene` to load a large scene that streams tiles in and out of diff --git a/docs/API/UsingEngineSettings.md b/docs/API/UsingEngineAPI.md similarity index 83% rename from docs/API/UsingEngineSettings.md rename to docs/API/UsingEngineAPI.md index d2750cc7..caa14d2b 100644 --- a/docs/API/UsingEngineSettings.md +++ b/docs/API/UsingEngineAPI.md @@ -28,6 +28,14 @@ setRendering(.postProcessing(.enabled)) setRendering(.postProcessing(.disabled)) ``` +Renderer extensions use the same rendering domain: + +```swift +setRendering(.extensions(.register(WaterRenderExtension()))) +setRendering(.extensions(.unregister("water"))) +setRendering(.extensions(.removeAll)) +``` + Wireframe parameters can also be configured through the same domain: ```swift @@ -186,6 +194,31 @@ setCamera(.defaultFOV(70.0)) setCamera(.clipPlanes(near: 0.1, far: 1000.0)) ``` +## Input (macOS) + +```swift +// Registration +registerKeyboardEvents() +unregisterKeyboardEvents() +registerMouseEvents() + +// Query +let keys = getKeyboardState() +let controller = getGameControllerState() +``` + +## Input (iOS) + +```swift +// Registration +registerTouchEvents(view: view) +unregisterTouchEvents() + +// Query +let touch = getIOSTouchState() +let controller = getGameControllerState() // game controller detection is automatic +``` + ## Input (XR) ```swift @@ -203,6 +236,25 @@ let state = getXRSpatialInputState() let ready = isXRSceneReady() ``` +## Input (PSVR2 Sense) + +```swift +// Config +setInput(.psvr2(.motionEnabled(true))) +setInput(.psvr2(.leftTriggerEffect(.weapon(startPosition: 0.2, endPosition: 0.8, strength: 1.0)))) +setInput(.psvr2(.leftTriggerEffect(.feedback(startPosition: 0.3, strength: 0.7)))) +setInput(.psvr2(.leftTriggerEffect(.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 15.0)))) +setInput(.psvr2(.leftTriggerEffect(.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 1.0)))) +setInput(.psvr2(.leftTriggerEffect(.off))) +setInput(.psvr2(.rightTriggerEffect(.weapon(startPosition: 0.2, endPosition: 0.8, strength: 1.0)))) + +// Query +let state = getPSVR2SenseState() +let connected = isPSVR2SenseConnected() +``` + +All position and strength values in `PSVR2TriggerEffect` are normalized to `[0, 1]`. Effects take effect immediately when a controller is connected; calls are silently ignored when no controller is paired. Motion data (`state.motionGravityX/Y/Z`, `state.motionRotationRateX/Y/Z`) is only populated when `motionEnabled` is `true`. + ## Spatial Manipulation (visionOS) Use `setSpatialManipulation` for tuning thresholds and behaviour: @@ -321,7 +373,11 @@ setBatching(.newProperty(value)) setSpatialDebug(.newProperty(value)) setLogger(.newProperty(value)) setCamera(.newProperty(value)) +registerKeyboardEvents() +registerMouseEvents() +registerTouchEvents(view: view) setInput(.xr(.newProperty(value))) +setInput(.psvr2(.newProperty(value))) setSpatialManipulation(.newProperty(value)) setLight(entityId: entity, .lightType(.newProperty(value))) setSceneChannel(.contextGeometry, .renderMode(.wireframe)) diff --git a/docs/API/UsingInputSystem.md b/docs/API/UsingInputSystem.md index c84c42dc..9c4d0519 100644 --- a/docs/API/UsingInputSystem.md +++ b/docs/API/UsingInputSystem.md @@ -13,8 +13,8 @@ Example: Detecting the 'W' Key ```swift func init(){ -// Make sure that you have enabled keyevents in your init function: -InputSystem.shared.registerKeyboardEvents() + // Register keyboard events once in your init function + registerKeyboardEvents() } // Then in the handleInput callback, you can do this: @@ -22,11 +22,10 @@ InputSystem.shared.registerKeyboardEvents() func handleInput() { // Skip logic if not in game mode if gameMode == false { return } - - let inputSystem = InputSystem.shared - // Handle input here - if inputSystem.keyState.wPressed{ + let keyState = getKeyboardState() + + if keyState.wPressed { Logger.log(message: "w pressed") } } @@ -34,17 +33,17 @@ func handleInput() { You can use the same logic for other keys like A, S, and D: ```swift -let inputSystem = InputSystem.shared - -if inputSystem.keyState.aPressed == true { +let keyState = getKeyboardState() + +if keyState.aPressed { // Move left } -if inputSystem.keyState.sPressed == true { +if keyState.sPressed { // Move backward } -if inputSystem.keyState.dPressed == true { +if keyState.dPressed { // Move right } ``` @@ -68,67 +67,148 @@ Here's an example function that moves a car entity based on keyboard inputs: ```swift func moveCar(entityId: EntityID, dt: Float) { - - let inputSystem = InputSystem.shared - - // Ensure we are in game mode - if gameMode == false { - return - } + if gameMode == false { return } + let keyState = getKeyboardState() var position = simd_float3(0.0, 0.0, 0.0) - // Move forward - if inputSystem.keyState.wPressed == true { - position.z += 1.0 * dt - } + if keyState.wPressed { position.z += 1.0 * dt } + if keyState.sPressed { position.z -= 1.0 * dt } + if keyState.aPressed { position.x -= 1.0 * dt } + if keyState.dPressed { position.x += 1.0 * dt } - // Move backward - if inputSystem.keyState.sPressed == true { - position.z -= 1.0 * dt - } + translateTo(entityId: entityId, position: position) +} +``` - // Move left - if inputSystem.keyState.aPressed == true { - position.x -= 1.0 * dt - } +## How to Use the Input System with a Game Controller - // Move right - if inputSystem.keyState.dPressed == true { - position.x += 1.0 * dt +Game controller detection is automatic — no registration call is needed. + +Example: Detecting the 'A' button + +```swift +func handleInput() { + if gameMode == false { return } + + let controller = getGameControllerState() + + if controller.aPressed { + Logger.log(message: "Pressed A button") } +} +``` - // Apply the translation to the entity - translateTo(entityId: entityId, position: position) +--- + +## Input Registration (macOS) + +Register keyboard and mouse event handling once in your init function: + +```swift +func gameInit() { + registerKeyboardEvents() + registerMouseEvents() } ``` -## How to Use the Input System with a Game Controller +Call `unregisterKeyboardEvents()` to stop receiving keyboard events (e.g. when leaving game mode). -To detect if a specific button is pressed, use the gameControllerState object from the Input System. +--- -Example: Detecting the 'A' button +## How to Use the Input System (iOS) + +On iOS, the engine registers UIKit gesture recognizers against your game view and writes all touch output into a dedicated `IOSTouchState` struct. This keeps iOS input separate from keyboard and mouse state and gives each gesture its own clearly named fields. + +### Step 1: Register touch events + +Pass your game view once during setup: ```swift -func init(){ -// Make sure that you have enabled game controller events in your init function: - InputSystem.shared.registerGameControllerEvents() +func gameInit(view: UIView) { + registerTouchEvents(view: view) } +``` -// Then in the handleInput callback, you can do this: +Call `unregisterTouchEvents()` to remove the gesture recognizers when no longer needed (e.g. when leaving the game scene). + +### Step 2: Read gesture state per frame + +Call `getIOSTouchState()` each frame to get a snapshot of all active gestures: +| Gesture | Fields populated | +|---|---| +| Single-finger drag | `isDragging`, `dragX`, `dragY`, `dragDeltaX`, `dragDeltaY`, `dragGestureState` | +| Tap (single) | `tapped` (brief pulse), `tapX`, `tapY` | +| Double tap | `doubleTapped` (brief pulse), `doubleTapX`, `doubleTapY` | +| Two-finger pan | `twoFingerPanning`, `twoFingerDeltaX`, `twoFingerDeltaY` | +| Pinch | `isPinching`, `pinchScaleDelta` (change in scale per frame; positive = spreading) | + +```swift func handleInput() { - // Skip logic if not in game mode if gameMode == false { return } - let inputSystem = InputSystem.shared - // Handle input here - if inputSystem.gameControllerState.aPressed { - Logger.log(message: "Pressed A key") + let touch = getIOSTouchState() + + // Single-finger drag + if touch.isDragging { + Logger.log(message: "Dragging at (\(touch.dragX), \(touch.dragY))") + Logger.log(message: "Delta: (\(touch.dragDeltaX), \(touch.dragDeltaY))") + } + + // Tap + if touch.tapped { + Logger.log(message: "Tapped at (\(touch.tapX), \(touch.tapY))") + } + + // Double tap + if touch.doubleTapped { + Logger.log(message: "Double-tapped at (\(touch.doubleTapX), \(touch.doubleTapY))") + } + + // Two-finger pan + if touch.twoFingerPanning { + Logger.log(message: "Two-finger pan delta: (\(touch.twoFingerDeltaX), \(touch.twoFingerDeltaY))") + } + + // Pinch to zoom + if touch.isPinching { + let zoom = touch.pinchScaleDelta + // apply zoom... } } ``` +### Step 3: Using touch input to move entities + +```swift +func handleInput() { + if gameMode == false { return } + + let touch = getIOSTouchState() + + guard touch.isDragging else { return } + + var position = simd_float3(0, 0, 0) + position.x += touch.dragDeltaX * 0.01 + position.z += touch.dragDeltaY * 0.01 + + translateTo(entityId: myEntity, position: position) +} +``` + +### Available IOSTouchState fields + +| Group | Fields | +|---|---| +| Single-finger drag | `isDragging`, `dragX`, `dragY`, `dragDeltaX`, `dragDeltaY`, `dragGestureState` | +| Tap | `tapped`, `tapX`, `tapY` | +| Double tap | `doubleTapped`, `doubleTapX`, `doubleTapY` | +| Two-finger pan | `twoFingerPanning`, `twoFingerDeltaX`, `twoFingerDeltaY` | +| Pinch | `isPinching`, `pinchScaleDelta` | + +`tapped` and `doubleTapped` are brief pulses — they auto-clear after ~0.1 s so you don't need to reset them manually. `dragDeltaX/Y` and `twoFingerDeltaX/Y` are reset to zero at the end of each gesture, so no manual reset is needed there either. + --- ## XR Input Configuration (visionOS) @@ -186,6 +266,100 @@ let ready = isXRSceneReady() --- +## How to Use the Input System (PlayStation VR2 Sense Controller) + +The PSVR2 Sense controller is detected automatically — no registration call is needed. The moment `InputSystem.shared` is first accessed, the engine begins listening for controller connect and disconnect events. When a PSVR2 Sense controller pairs, it is configured automatically. + +### Step 1: Read controller state per frame + +```swift +func handleInput() { + let state = getPSVR2SenseState() + + // Buttons unique to the PSVR2 Sense + if state.createButtonPressed { + Logger.log(message: "Create button pressed") + } + + if state.touchpadButtonPressed { + Logger.log(message: "Touchpad clicked") + } + + // Touchpad surface position (−1…1 per axis) + if state.touchpadTouched { + Logger.log(message: "Finger at (\(state.touchpadX), \(state.touchpadY))") + } + + // Adaptive trigger pull depth (0…1) + if state.leftAdaptiveTriggerValue > 0.5 { + Logger.log(message: "Left trigger more than half pulled") + } +} +``` + +### Step 2: Check connection status + +```swift +if isPSVR2SenseConnected() { + Logger.log(message: "PSVR2 Sense is connected") +} +``` + +### Step 3: Apply adaptive trigger effects + +Trigger effects can be set at any time. Calls are silently ignored when no controller is paired. + +```swift +// Simulate weapon resistance — builds from 20% to 80% of travel, then releases +setInput(.psvr2(.leftTriggerEffect(.weapon(startPosition: 0.2, endPosition: 0.8, strength: 1.0)))) + +// Constant resistance from 30% travel onwards +setInput(.psvr2(.rightTriggerEffect(.feedback(startPosition: 0.3, strength: 0.7)))) + +// Repeating vibration strike +setInput(.psvr2(.leftTriggerEffect(.vibration(startPosition: 0.0, amplitude: 0.5, frequency: 15.0)))) + +// Linearly interpolated resistance between two positions +setInput(.psvr2(.leftTriggerEffect(.slopeFeedback(startPosition: 0.1, endPosition: 0.9, startStrength: 0.2, endStrength: 1.0)))) + +// Clear all effects +setInput(.psvr2(.leftTriggerEffect(.off))) +setInput(.psvr2(.rightTriggerEffect(.off))) +``` + +All position and strength values are normalized to `[0, 1]`. For `.weapon` and `.slopeFeedback`, `endPosition` must be greater than `startPosition`. + +### Step 4: Enable motion sensing (optional) + +Motion is disabled by default to avoid unnecessary sensor activation. Enable it when your game needs gravity or rotation rate data: + +```swift +// Enable once (e.g. in gameInit) +setInput(.psvr2(.motionEnabled(true))) + +// Then read per frame +func handleInput() { + let state = getPSVR2SenseState() + let gravity = simd_float3(state.motionGravityX, state.motionGravityY, state.motionGravityZ) + let rotationRate = simd_float3(state.motionRotationRateX, state.motionRotationRateY, state.motionRotationRateZ) +} +``` + +You can toggle motion off again at any time with `setInput(.psvr2(.motionEnabled(false)))`. + +### Available PSVR2SenseControllerState fields + +| Group | Fields | +|---|---| +| Connection | `isConnected` | +| Buttons | `createButtonPressed`, `homeButtonPressed`, `touchpadButtonPressed` | +| Touchpad surface | `touchpadX`, `touchpadY`, `touchpadTouched` | +| Adaptive triggers | `leftAdaptiveTriggerValue`, `rightAdaptiveTriggerValue` | +| Motion (gravity) | `motionGravityX`, `motionGravityY`, `motionGravityZ` | +| Motion (rotation rate) | `motionRotationRateX`, `motionRotationRateY`, `motionRotationRateZ` | + +--- + ## Tips and Best Practices - Debouncing: If you want to execute an action only once per key press, track the key's previous state to avoid repeated triggers. - Game Mode Check: Always ensure the game is in the appropriate mode (e.g., Game Mode) before processing inputs. diff --git a/docs/API/UsingLODSystem.md b/docs/API/UsingLODSystem.md index 2d173895..a69783f2 100644 --- a/docs/API/UsingLODSystem.md +++ b/docs/API/UsingLODSystem.md @@ -10,40 +10,6 @@ The LOD system allows you to: - Customize distance thresholds for each LOD level - Configure LOD behavior (bias, hysteresis, fade transitions) -> **Choose Your Path:** You can set up LOD via the **Editor** (no code required) or **programmatically** in Swift. - ---- - -## Using the Editor - -### Adding LOD Support to an Entity - -1. **Select your entity** in the Scene Hierarchy -2. **Open the Inspector** and click **"Add Components"** -3. **Select "LOD Component"** from the list -4. An LOD Levels panel will appear in the Inspector - -### Adding LOD Levels - -1. **Select a model** from the Asset Browser (Models folder) -2. In the LOD Levels panel, click **"Add LOD Level"** -3. The selected model will be added as the next LOD level with a default distance: - - LOD0: 50 units - - LOD1: 100 units - - LOD2: 150 units, etc. - -### Adjusting LOD Distances - -1. Click the **distance value** next to any LOD level -2. Enter a new distance and press **Enter** -3. Objects will switch to this LOD when the camera is within this distance - -### Removing LOD Levels - -Click the **trash icon** next to any LOD level to remove it. - ---- - ## Using Code ### Quick Start diff --git a/docs/API/UsingRenderingExtensions.md b/docs/API/UsingRenderingExtensions.md new file mode 100644 index 00000000..13a3f6a8 --- /dev/null +++ b/docs/API/UsingRenderingExtensions.md @@ -0,0 +1,586 @@ +# Using Rendering Extensions + +Rendering Extensions add optional render passes, shaders, pipelines, and owned +resources without modifying Untold Engine. There are two supported integration +styles: + +| Use case | Integration | Registration | +| -------- | ----------- | ------------ | +| Rendering code belongs to one application | Application-local extension | `setRendering(.extensions(.register(...)))` | +| Rendering code is distributed as a Swift package or framework | Package plugin | A package-owned installation function such as `registerWaterRenderPlugin()` | + +Both styles use the same `RenderExtension` API and can be active in the same +application. Choose the package plugin when another project or developer should +consume the extension as a dependency. + +- [Rendering Extension examples](../../Examples/RenderingExtensions/README.md) +- [Application-local model-surface example](../../Examples/RenderingExtensions/ApplicationLocal/README.md) +- [Swift package plugin example](../../Examples/RenderingExtensions/SwiftPackagePlugin/README.md) +- [Rendering Extensions architecture](../Architecture/RenderingExtensions.md) + +> The water package is an API acceptance fixture, not a production water renderer. Its shaders are intentionally minimal. + +## Application-Local Extension + +Use this workflow when the extension source and shaders live in the application +or one of its frameworks. + +### 1. Implement `RenderExtension` + +Every extension needs a stable, globally unique `id` and a `buildGraph` method. +The registration hooks have default no-op implementations; implement only the +ones your feature needs. + +| Member | Implement when | +| ------ | -------------- | +| `id` | Always. Namespace it for collision avoidance and lifecycle ownership. | +| `registerShaderLibraries` | The extension supplies Metal functions. | +| `registerResources` | The extension owns textures or buffers. | +| `registerArgumentBuffers` | A model-surface fragment shader reads extension arguments. | +| `registerPipelines` | The extension creates a render pipeline. | +| `registerComputePipelines` | The extension dispatches compute work. | +| `buildGraph` | Always. Add the passes that perform the rendering work. | + +A minimal resource-owning extension looks like this: + +```swift +import Metal +import UntoldEngine + +final class ReflectionRenderExtension: RenderExtension, @unchecked Sendable { + let id = "com.example.reflection" + + private let reflectionID: RenderTextureResourceID = + "com.example.reflection.color" + + func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: reflectionID, + label: "Reflection Color", + size: .viewportScale(1.0), + pixelFormat: .rgba16Float, + usage: [.renderTarget, .shaderRead] + ) + ) + } + + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + builder.addPass( + id: "com.example.reflection.render", + stage: .beforePostProcess, + resources: [.texture(reflectionID, access: .renderTarget)] + ) { context in + guard let reflection = context.resources.texture(reflectionID) else { + return + } + + // Encode Metal commands targeting reflection. + } + } +} +``` + +### 2. Register Before Renderer Creation + +```swift +setRendering(.extensions(.register(ReflectionRenderExtension()))) +``` + +For the complete tint sample: + +```swift +setRendering(.extensions(.register(TintSurfaceRenderExtension()))) + +let tint = createEntity() +setEntityMeshDirect( + entityId: tint, + meshes: BasicPrimitives.createPlane(), + assetName: "tint" +) +registerComponent(entityId: tint, componentType: TintSurfaceComponent.self) +getEntityComponent( + entityId: tint, + componentType: TintSurfaceComponent.self +)?.color = SIMD4(0.95, 0.05, 0.0, 0.8) +``` + +Add both `TintSurfaceRenderExtension.swift` and `TintSurface.metal` from the +sample to the application target. Put the Metal file in the application's +Compile Sources build phase. The sample's default initializer loads the Metal +library from `Bundle.main`. A framework should pass its own bundle instead. + +### 3. Remove or Replace + +```swift +setRendering(.extensions(.unregister("com.example.reflection"))) +setRendering(.extensions(.removeAll)) +``` + +Registering another standalone extension with the same `id` replaces the +previous instance if the new registration succeeds. A failed replacement leaves +the previous extension active. + +## Swift Package Plugin + +Use a package plugin when the extension is a reusable product consumed through +Swift Package Manager or a framework dependency. + +The complete buildable reference is +[`Examples/RenderingExtensions/SwiftPackagePlugin`](../../Examples/RenderingExtensions/SwiftPackagePlugin/README.md). +It contains the Swift extension, manifest, registration entry point, argument +buffer shader, declarations, tests, and bundled precompiled metallib. + +### 1. Declare the Engine Dependency + +During local development, point the extension package to the engine checkout: + +```swift +dependencies: [ + .package(path: "/path/to/UntoldEngine"), +] +``` + +For distribution, use the canonical engine repository URL and a compatible +release requirement. The plugin target depends on the engine product: + +```swift +.target( + name: "ExampleRenderPlugin", + dependencies: [ + .product(name: "UntoldEngine", package: "UntoldEngine"), + ], + exclude: ["Shaders"], + resources: [ + .copy("Resources/ExampleRenderPlugin.metallib"), + ] +) +``` + +Dependency paths are resolved relative to the extension package's +`Package.swift`, not relative to the consuming application. SwiftPM copies Metal +source but does not compile it for this workflow. Build and bundle a compatible +metallib for every platform and SDK the package supports. + +### 2. Add a Manifest and Factory + +The package wraps one or more ordinary `RenderExtension` implementations: + +```swift +public struct ExampleRenderPlugin: RenderExtensionPlugin { + public let manifest = RenderExtensionPluginManifest( + id: "com.example.rendering", + displayName: "Example Rendering", + version: RenderExtensionPluginVersion(major: 1, minor: 0, patch: 0) + ) + + public init() {} + + public func makeRenderExtensions() -> [any RenderExtension] { + [ExampleSurfaceRenderExtension(shaderBundle: .module)] + } +} +``` + +Plugin IDs must be dot-separated package namespaces. Every extension ID supplied +by a plugin must equal the plugin ID or begin with that ID followed by a dot. + +Use `Bundle.module` from code inside the package target when constructing the +extension. + +### 3. Export One Installation Function + +```swift +@discardableResult +public func registerExampleRenderPlugin() + -> RenderExtensionPluginInstallationResult +{ + RenderExtensionPluginRegistry.shared.install(ExampleRenderPlugin()) +} +``` + +### 4. Install and Use It in the Application + +Install the plugin once during application startup, before renderer creation: + +```swift +import ExampleRenderPlugin +import UntoldEngine + +switch registerExampleRenderPlugin() { +case .installed, .replaced: + break + +case let .rejected(failure): + print("Plugin validation errors:", failure.validationErrors) + print("Artifact conflicts:", failure.artifactConflicts) + print("Graph errors:", failure.graphValidationErrors) +} +``` + +The package should expose any components or configuration types that consumers +need. After `registerWaterRenderPlugin()` returns `.installed` or `.replaced`, +the fixture component can be used like this: + +```swift +import WaterRenderPlugin + +let water = createEntity() +setEntityMeshDirect( + entityId: water, + meshes: BasicPrimitives.createPlane(), + assetName: "water" +) +registerComponent(entityId: water, componentType: WaterSurfaceComponent.self) + +if let surface = getEntityComponent( + entityId: water, + componentType: WaterSurfaceComponent.self +) { + surface.tint = SIMD4(0.08, 0.38, 0.62, 1.0) + surface.roughness = 0.08 + surface.waveStrength = 0.18 +} +``` + +Do not also register the package's internal extension through `setRendering`. +The package entry point already registers it. + +### 5. Query or Uninstall + +```swift +let manifests = RenderExtensionPluginRegistry.shared.installedManifests() +let failure = RenderExtensionPluginRegistry.shared.failure( + forPluginID: "com.example.rendering" +) + +RenderExtensionPluginRegistry.shared.uninstall(id: "com.example.rendering") +RenderExtensionPluginRegistry.shared.removeAll() +``` + +Plugin `removeAll()` removes plugin-owned extensions only. It does not remove +standalone application-local extensions. + +## Stable Render Stages + +Add passes at stable stage anchors: + +```swift +.afterOpaqueLighting +.beforeTransparency +.afterTransparency +.beforePostProcess +.afterPostProcess +.beforeComposite +.beforeLook +.beforeOutput +``` + +Choose the earliest stage that provides the inputs your pass needs. A surface +that should run before tone mapping can use `.beforePostProcess`; a final overlay +can use `.beforeOutput`. + +Do not depend on built-in pass names such as `"lightPass"` or `"precomp"`. +Extensions cannot construct raw engine passes or declare dependencies on private +pass IDs. + +## Pass Resource Access + +A pass must declare every extension-owned texture and buffer it accesses: + +```swift +builder.addPass( + id: "com.example.water.simulation", + stage: .beforePostProcess, + resources: [ + .texture(colorTextureID, access: .write), + .buffer(uniformsID, access: .read), + ] +) { context in + guard let color = context.resources.texture(colorTextureID), + let uniforms = context.resources.buffer(uniformsID) + else { + return + } + + // Encode commands. +} +``` + +Texture access supports `.read`, `.write`, and `.renderTarget`. Buffer access +supports `.read` and `.write`. Descriptor usage must support the declared pass +access: shader writes require `.shaderWrite`, and render-target access requires +`.renderTarget`. + +## Textures and Buffers + +Declare resources in `registerResources`: + +```swift +func registerResources(_ registry: RenderResourceRegistry) { + registry.registerTexture( + RenderExtensionTextureDescriptor( + id: "com.example.water.reflection", + size: .viewportScale(1.0), + pixelFormat: .rgba16Float, + usage: [.renderTarget, .shaderRead] + ) + ) + + registry.registerBuffer( + RenderExtensionBufferDescriptor( + id: "com.example.water.uniforms", + length: 256 + ) + ) +} +``` + +Texture sizes can be fixed or viewport-relative. Viewport-relative textures are +recreated when their resolved size changes. + +Outside a pass, query a resource or its state through the public API: + +```swift +let texture = getRenderResource(.texture("com.example.water.reflection")) +let state = RenderResourceRegistry.shared.textureState( + "com.example.water.reflection" +) +``` + +## Shader Libraries + +An application target can use its default Metal library: + +```swift +func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerDefaultLibrary( + "com.example.water.shaders", + bundle: .main + ) +} +``` + +A package should resolve its precompiled metallib from package-owned code: + +```swift +func registerShaderLibraries(_ registry: RenderShaderLibraryRegistry) { + registry.registerLibrary( + "com.example.water.shaders", + bundle: .module, + resource: "WaterShaders", + subdirectory: "Shaders" + ) +} +``` + +Frameworks should pass their framework bundle. Missing or invalid libraries are +reported as registration failures. + +## Compute and Render Pipelines + +Register a compute pipeline from a registered shader library: + +```swift +func registerComputePipelines(_ registry: ComputePipelineRegistry) { + registry.registerComputePipeline( + RenderExtensionComputePipelineDescriptor( + id: "com.example.water.simulation", + function: "waterSimulationKernel", + shaderLibrary: .registered("com.example.water.shaders"), + name: "Water Simulation" + ) + ) +} +``` + +Retrieve it from a pass: + +```swift +guard let pipeline = context.computePipelines.pipeline( + "com.example.water.simulation" +) else { + return +} +``` + +Register a custom render pipeline with +`RenderExtensionRenderPipelineDescriptor`, or use the model-surface helper when +drawing normal engine meshes: + +```swift +func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerModelSurfacePipeline( + "com.example.water.surface", + fragmentShader: "waterFragment", + fragmentShaderLibrary: .registered("com.example.water.shaders"), + name: "Water Surface", + validation: .warn( + argumentLayoutID: "com.example.water.surface.arguments" + ) + ) +} +``` + +## Model-Surface Argument Buffers + +Model-surface extensions should pass custom textures, samplers, and buffers +through the engine extension argument buffer rather than raw Metal binding slots. + +### Register the Layout + +```swift +func registerArgumentBuffers( + _ registry: RenderExtensionArgumentBufferRegistry +) { + registry.registerArgumentBuffer( + RenderExtensionArgumentBufferDescriptor( + id: "com.example.water.surface.arguments", + textures: [ + RenderExtensionArgumentTexture( + id: RenderExtensionModelSurfaceArgument.texture0 + ), + ], + samplers: [ + RenderExtensionArgumentSampler( + id: RenderExtensionModelSurfaceArgument.sampler0 + ), + ], + buffers: [ + RenderExtensionArgumentBuffer( + id: RenderExtensionModelSurfaceArgument.buffer0 + ), + ] + ) + ) +} +``` + +### Bind Per-Entity Values + +```swift +context.drawModelSurfaceEntities( + pipeline: "com.example.water.surface", + matching: [WaterComponent.self], + label: "Water Surface", + argumentLayoutID: "com.example.water.surface.arguments", + bindArguments: { arguments, entityID, resources in + guard let water = getEntityComponent( + entityId: entityID, + componentType: WaterComponent.self + ) else { + return + } + + arguments.setTexture( + resources.texture(water.colorTextureID), + id: RenderExtensionModelSurfaceArgument.texture0 + ) + arguments.setSampler( + water.sampler, + id: RenderExtensionModelSurfaceArgument.sampler0 + ) + + var uniforms = WaterSurfaceUniforms(component: water) + arguments.setBytes( + &uniforms, + id: RenderExtensionModelSurfaceArgument.buffer0 + ) + } +) +``` + +Include every resource read through `resources` in the pass's `resources:` +declaration. + +### Match the Metal Shader + +```metal +#include + +using namespace metal; + +fragment float4 waterFragment( + UntoldModelSurfaceVertexOut in [[stage_in]], + constant UntoldModelSurfaceExtensionArguments &arguments + [[buffer(UntoldModelSurfaceExtensionArgumentBufferIndex)]] +) { + constant float4 &tint = + *reinterpret_cast(arguments.buffer0); + return tint; +} +``` + +Use the named Swift and shader-support constants shown above instead of numeric +binding indices. + +For a complete implementation, shader, and package compilation commands, use the +[application-local argument-buffer sample](../../Examples/RenderingExtensions/ApplicationLocal/README.md). + +## Migrating a Raw-Slot Extension + +1. Include `UntoldModelSurface.h` in the fragment shader. +2. Replace raw extension texture, sampler, and buffer parameters with + `UntoldModelSurfaceExtensionArguments`. +3. Register the local member IDs under a namespaced argument-layout ID. +4. Pass that layout ID to pipeline validation and `drawModelSurfaceEntities`. +5. Encode values through `bindArguments` and remove equivalent raw encoder + bindings. +6. Resolve every `.warn(argumentLayoutID:)` diagnostic before release. + +The legacy raw-slot API remains available for migration. New model-surface +extensions should use argument buffers. + +## Registration Errors and Diagnostics + +Use direct standalone registration when the application needs a structured +result: + +```swift +let result = RenderExtensionRegistry.shared.register( + ReflectionRenderExtension() +) +``` + +Query registration diagnostics by extension ID: + +```swift +let conflicts = RenderExtensionRegistry.shared.registrationConflicts( + forExtensionID: "com.example.reflection" +) +let shaderErrors = RenderExtensionRegistry.shared.shaderLibraryErrors( + forExtensionID: "com.example.reflection" +) +let pipelineErrors = RenderExtensionRegistry.shared.pipelineErrors( + forExtensionID: "com.example.reflection" +) +let graphErrors = RenderExtensionRegistry.shared.graphValidationErrors( + forExtensionID: "com.example.reflection" +) +let allocationErrors = RenderExtensionRegistry.shared.resourceAllocationErrors( + forExtensionID: "com.example.reflection" +) +``` + +Package consumers receive plugin-wide failures from +`RenderExtensionPluginInstallationResult.rejected` and can query the latest +failure through `RenderExtensionPluginRegistry`. + +## Authoring Checklist + +- Register the extension or plugin once before renderer creation. +- Namespace every extension, plugin, pass, resource, library, pipeline, and + argument-layout ID. +- Add passes only through stable stages. +- Declare every resource access on the pass that uses it. +- Use argument buffers for model-surface extension data. +- Keep `Bundle.module` inside the package target. +- Bundle one compatible metallib per supported platform and SDK. +- Handle registration rejection and inspect registration diagnostics. + +For lifecycle, ownership, graph compilation, hazard scheduling, resource +planning, and failure-isolation details, see +[Rendering Extensions Architecture](../Architecture/RenderingExtensions.md). diff --git a/docs/API/UsingRenderingSystem.md b/docs/API/UsingRenderingSystem.md index 24db0be6..838acdad 100644 --- a/docs/API/UsingRenderingSystem.md +++ b/docs/API/UsingRenderingSystem.md @@ -119,4 +119,7 @@ Restore normal rendering with `setRendering(.debugView(.lit))`. For the broader settings style, see [Engine Settings API](UsingEngineSettings.md). +For optional renderer features that add their own graph passes or resources, +see [Rendering Extensions](UsingRenderingExtensions.md). + --- diff --git a/docs/API/UsingUntoldEngineCLI.md b/docs/API/UsingUntoldEngineCLI.md index 959cfb40..06d04885 100644 --- a/docs/API/UsingUntoldEngineCLI.md +++ b/docs/API/UsingUntoldEngineCLI.md @@ -52,7 +52,7 @@ Run from the parent directory — the CLI creates the project folder for you: ```bash cd ~/Downloads -untoldengine-create create MyGame +untoldengine-create MyGame ``` ### Platform Options @@ -67,19 +67,19 @@ untoldengine-create create MyGame ```bash # macOS project (default) -untoldengine-create create MyGame --platform macos +untoldengine-create MyGame --platform macos # iOS project -untoldengine-create create MyGame --platform ios --bundle-id com.company.mygame +untoldengine-create MyGame --platform ios --bundle-id com.company.mygame # iOS with ARKit -untoldengine-create create ARGame --platform ios-ar --bundle-id com.company.argame +untoldengine-create ARGame --platform ios-ar --bundle-id com.company.argame # visionOS / Apple Vision Pro -untoldengine-create create VisionGame --platform visionos +untoldengine-create VisionGame --platform visionos # Multi-platform (macOS, iOS, visionOS) — Team ID required for signing -untoldengine-create create CrossGame --platform multi --team-id ABCD1234EF +untoldengine-create CrossGame --platform multi --team-id ABCD1234EF ``` ### All Options diff --git a/docs/Architecture/RenderingExtensions.md b/docs/Architecture/RenderingExtensions.md new file mode 100644 index 00000000..9138dc6c --- /dev/null +++ b/docs/Architecture/RenderingExtensions.md @@ -0,0 +1,343 @@ +# Rendering Extensions Architecture + +This document explains how Rendering Extensions are represented, registered, +compiled, validated, and executed inside Untold Engine. For authoring and +consumer examples, see [Using Rendering Extensions](../API/UsingRenderingExtensions.md). + +## Design Goals + +Rendering Extensions provide optional rendering features without exposing the +engine's private render-pass implementation. The architecture is designed to: + +- preserve a valid engine graph when an extension fails; +- isolate independently developed providers; +- keep extension resource and pipeline ownership explicit; +- provide stable insertion points without exposing built-in pass names; +- avoid raw Metal binding-slot collisions in model-surface shaders; +- compile mutable declarations into one deterministic executable graph; +- support application-local extensions and distributable package plugins. + +## Public Layers + +The system has two lifecycle layers. + +### `RenderExtension` + +`RenderExtension` is the unit of rendering behavior. It owns one stable ID and +may contribute: + +- shader libraries; +- texture and buffer declarations; +- argument-buffer layouts; +- render and compute pipelines; +- staged graph passes. + +Only `id` and `buildGraph` are required. The registration hooks have default +no-op implementations. + +Application-local extensions register directly through `RenderExtensionRegistry` +or the `setRendering` facade. + +### `RenderExtensionPlugin` + +`RenderExtensionPlugin` is a statically linked package or framework contract +above one or more extensions. Its manifest declares: + +- a namespaced plugin ID; +- a display name; +- a semantic release version; +- the exact Rendering Extension API version it requires. + +The plugin factory creates all extensions supplied by that module. Installation, +replacement, and removal treat the complete set as one transaction. + +Plugins are not runtime-loaded binaries. SwiftPM or the application build system +links their modules into the application before launch. + +## Identity and Ownership + +Extension-owned artifacts are tracked by owner ID. IDs are global within each +artifact domain: + +- render passes; +- shader libraries; +- textures and buffers; +- render and compute pipelines; +- argument-buffer layouts. + +Providers must namespace IDs because registration rejects an artifact already +owned by another extension or by the engine. + +Resources are owner-private. A pass may access only a resource declared by the +same extension. Knowing another provider's resource ID does not grant access. +Cross-extension exports and imports are not part of the current contract. + +Plugin extension IDs must equal the plugin ID or begin with the plugin ID plus a +dot. This lets the registry validate ownership before mutating renderer state. + +## Registration Lifecycle + +Registration is serialized so graph construction cannot observe a partially +installed or removed extension. + +For a standalone extension, the engine: + +1. records the extension ID and checks plugin ownership conflicts; +2. collects and validates argument-buffer layouts; +3. resolves shader libraries and creates pipelines when Metal is already ready; +4. collects and validates resource declarations, committing them only if all + currently available artifact registration succeeded; +5. retains the extension for staged graph contribution; +6. materializes deferred shader and pipeline declarations after Metal becomes + ready; +7. removes all newly created artifacts if any required registration fails. + +Resources can be declared before a Metal device or valid viewport exists. Their +state begins as `declared` and transitions to `allocated` when allocation is +possible. Failed allocation moves a resource to `invalidated`; a later refresh +can retry it. Unregistration moves owned resources to `released` and removes +their backing objects. + +A same-ID standalone registration is a replacement transaction. The previous +extension and its artifacts are restored if the replacement fails. + +## Plugin Transactions + +Plugin installation starts with pure manifest validation. Validation checks API +compatibility, plugin and extension namespaces, empty declarations, and duplicate +identities without registering anything. + +After validation, `RenderExtensionPluginRegistry` installs each supplied +extension under the shared lifecycle lock. If any extension fails: + +- earlier siblings from that installation are removed; +- partial resources and pipeline artifacts are released; +- a failed replacement restores the previous plugin and extension order; +- one structured plugin failure records the contributing errors. + +Uninstalling a plugin removes every artifact owned by all its extensions. +Attempting to unregister one plugin-owned extension through the standalone +registry removes its complete owning plugin, preserving the package transaction +boundary. + +## Shader and Pipeline Packaging + +Shader registration is independent from pipeline creation. An extension can: + +- supply an existing `MTLLibrary`; +- load a bundle's default library; +- resolve a precompiled metallib by bundle-relative resource name or URL. + +Package code resolves `Bundle.module` internally and passes that bundle to the +shader registry. The consumer does not access the package bundle directly. + +Render and compute pipeline descriptors are preflighted before Metal creation. +Validation resolves referenced libraries and functions, checks render-target +formats and argument-layout dependencies, and detects duplicate IDs. Creation +errors participate in the same extension or plugin transaction. + +Pipeline materialization may be deferred until the renderer has a Metal device. +A deferred failure removes the owning standalone extension or complete plugin. + +## Model-Surface Argument Isolation + +Engine model drawing already occupies low Metal texture, sampler, and buffer +slots. Allowing extensions to bind arbitrary raw indices would let two providers +or an extension and the engine overwrite each other's bindings. + +Model-surface extension shaders instead receive one fixed +`UntoldModelSurfaceExtensionArguments` argument buffer at the engine-owned +`UntoldModelSurfaceExtensionArgumentBufferIndex`. + +The outer Metal slot is shared by the ABI, but every draw binds only the active +pipeline's encoded argument buffer. Members such as `texture0`, `sampler0`, and +`buffer0` are local IDs within that buffer. A water extension and a grass +extension can therefore use the same local member IDs without sharing Metal +resource slots. + +`RenderExtensionArgumentBufferDescriptor` records which local members an +extension uses. The engine validates the referenced layout, creates an encoder +from the pipeline reflection data, encodes per-entity values, makes referenced +resources resident, and binds the resulting buffer before drawing. + +The fixed shader ABI contains the complete local ranges: + +- textures `0...7`; +- samplers `8...15`; +- buffers `16...31`. + +Registered layouts identify active members and access requirements; they do not +change the fixed ABI structure size. + +## Staged Graph Contribution + +Extensions do not create raw engine `RenderPass` values or name built-in pass +dependencies. `RenderGraphBuilder` exposes stable stage anchors instead: + +```swift +.afterOpaqueLighting +.beforeTransparency +.afterTransparency +.beforePostProcess +.afterPostProcess +.beforeComposite +.beforeLook +.beforeOutput +``` + +During graph construction, each extension receives an owner-scoped builder. +Staged pass declarations are collected with their owner, resource usages, and +execution closure. Pass IDs are checked against other providers and reserved +engine IDs. A conflict discards that extension's complete staged contribution. + +When the engine resolves a stage, it inserts its pending passes after the stage +anchor. Passes retain registration order within the same stage, producing an +explicit dependency chain. + +Pass closures do not execute during registration or graph construction. They are +captured for later command encoding. + +## Resource Access Declarations + +Each staged pass declares typed texture and buffer access. Construction checks: + +- the resource exists; +- the pass owner owns it; +- the descriptor supports the requested Metal usage; +- the pass does not request an invalid access combination. + +The pass context is capability-scoped. It exposes only resources listed in that +pass declaration, so an undeclared lookup returns `nil` even when the extension +owns the resource. + +Typed declarations also provide the input used by hazard scheduling and lifetime +analysis. + +## Graph Compilation Pipeline + +After all built-in and extension stages have been resolved, the mutable graph is +compiled into an immutable `CompiledRenderGraph`. + +### 1. Declaration Validation + +Compilation first reports missing pass dependencies and invalid resource +declarations. If dependencies are complete, it topologically sorts the explicit +and stage-generated graph. Dictionary insertion order does not affect the result. + +### 2. Resource-Hazard Scheduling + +For each typed resource, compilation merges a pass's declarations and derives +ordering constraints for: + +- read after write (RAW); +- write after read (WAR); +- write after write (WAW); +- render-target writes followed by shader access. + +Inferred dependencies are stored separately from author-declared dependencies +for diagnostics. The scheduler never removes or reverses an explicit or stage +edge. If an explicit order contradicts required resource flow, validation reports +the conflict rather than silently changing author intent. + +Resources with no graph writer remain valid because persistent data may be +initialized outside graph execution. + +### 3. Whole-Graph Validation + +The scheduled graph is sorted again and checked as a whole. Every reader of a +graph-written resource must be the writer or depend on a writer, and every pair +of writers must have a dependency path ordering them. + +Diagnostics retain extension ownership so a failing provider can be isolated. + +### 4. Immutable Executable Graph + +A valid graph becomes an immutable ordered array and ID lookup table. Each +compiled pass snapshots: + +- explicit and inferred dependencies; +- typed resource usage; +- extension ownership; +- stable stage; +- execution closure. + +Frame encoding iterates this ordered array. It does not sort or reinterpret the +mutable builder state again. + +### 5. Resource Lifetime Plan + +Compilation records the first and last execution index for every used declared +resource. Persistent resources are never alias candidates. + +Resources marked `.transient` receive deterministic alias-slot assignments when +all of these conditions hold: + +- their execution intervals do not overlap; +- they have the same owner; +- they have the same resource kind; +- their allocation-relevant descriptor properties match. + +This is currently planning metadata. The registry still allocates one Metal +resource per declaration; physical backing-store aliasing is not enabled. + +### 6. Optimization Audit + +The compiled graph stores a non-destructive optimization report containing: + +- pass and dependency counts; +- explicit and inferred dependency counts; +- persistent and transient resource counts; +- alias-slot and potential backing-store reduction counts; +- unused resource declarations; +- direct dependency edges already covered by another path; +- resource-plan consistency issues. + +The audit does not remove passes, dependencies, or resources. Safe pass culling +requires explicit output-root and side-effect contracts that the current API +does not provide. + +## Failure Isolation and Graph Rebuild + +Registration errors are recorded before graph construction. Graph-level errors +can appear later when extension passes are combined with the complete engine +graph. + +When whole-graph validation attributes an error to a standalone extension, the +engine removes that extension, releases its artifacts, and rebuilds the graph so +healthy extensions continue rendering. + +When the failing extension belongs to a plugin, the complete plugin is removed, +including sibling extensions. Plugin failure metadata retains the graph errors. + +An unattributed engine graph failure skips rendering for that frame rather than +executing a partial or invalid graph. + +## Execution Model + +Graph construction and compilation are declarative. During frame encoding, the +renderer walks `CompiledRenderGraph.orderedPasses` and invokes each captured +closure with the current command buffer and scoped resource access. + +No extension code can reorder arbitrary built-in passes. Extensions should rely +only on stable stage semantics and declared resource flow. + +## Current Boundaries + +The implemented architecture intentionally does not provide: + +- runtime discovery or dynamic loading of unknown extension binaries; +- cross-extension resource exports or imports; +- arbitrary dependencies on private engine pass IDs; +- physical transient resource aliasing; +- destructive pass or dependency pruning; +- argument-buffer helpers for every engine material path. + +The argument-buffer helper currently targets model-surface fragment shaders. +Built-in model and material migration can proceed independently without changing +the extension contract. + +## Reference Implementations + +- [Application-local argument-buffer sample](../../Examples/RenderingExtensions/ApplicationLocal/README.md) +- [External package acceptance fixture](../../Examples/RenderingExtensions/SwiftPackagePlugin/README.md) +- [Rendering System architecture](renderingSystem.md) diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index 70d208dc..289d0705 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -81,7 +81,17 @@ struct RenderPass { } ``` -Each pass declares which other passes must complete before it can run. `buildGameModeGraph()` assembles these nodes into a dictionary and returns it. Nothing executes yet — this is purely declarative. +Each engine pass declares which other passes must complete before it can run. +Before encoding, `buildExecutableGameModeGraph()` validates and compiles the +mutable builder output into an immutable `CompiledRenderGraph` with one +deterministic execution order. Frame encoding walks that snapshot rather than +sorting or reinterpreting mutable graph state. + +Rendering Extensions contribute owner-scoped passes through stable stage anchors +before this compilation step. Their registration lifecycle, plugin transactions, +resource validation, hazard scheduling, argument-buffer isolation, lifetime +planning, and failure recovery are documented separately in +[Rendering Extensions Architecture](RenderingExtensions.md). The full graph for a typical frame looks like this: diff --git a/docs/Architecture/xrRenderingSystem.md b/docs/Architecture/xrRenderingSystem.md index 9e2efdfb..ac259cda 100644 --- a/docs/Architecture/xrRenderingSystem.md +++ b/docs/Architecture/xrRenderingSystem.md @@ -162,7 +162,7 @@ The compositor provides the exact asymmetric projection for each eye. This accou **Pass descriptor**: Pre-allocated (`passDescriptorLeft`, `passDescriptorRight`) and reused every frame to avoid 180 allocations/second (2 eyes × 90 FPS). The color and depth textures are swapped in from `drawable.colorTextures[viewIndex]` and `drawable.depthTextures[viewIndex]`. -**`renderer.renderXR(...)`** calls `buildGameModeGraph()` + `topologicalSortGraph()` + `executeGraph()` — the exact same render graph pipeline as macOS. The only thing that changes is: +**`renderer.renderXR(...)`** calls `buildExecutableGameModeGraph()` + `executeGraph()` — the same validated render graph pipeline as macOS. Invalid graphs skip encoding for the affected frame. The only thing that changes is: - `renderInfo.currentEye = viewIndex` — tells uniform uploads which eye's matrices to use - The base pass mode: `.mixed` immersion omits the base pass (camera passthrough is the background), `.full` immersion renders the skybox diff --git a/scripts/install-untoldengine-create.sh b/scripts/install-untoldengine-create.sh index 7fcf02db..c2f48e76 100755 --- a/scripts/install-untoldengine-create.sh +++ b/scripts/install-untoldengine-create.sh @@ -98,14 +98,14 @@ if command -v $CLI_NAME &> /dev/null; then echo -e " ${CYAN}$CLI_NAME --help${NC}" echo "" echo "Examples:" - echo -e " ${CYAN}# Create macOS project${NC}" - echo -e " ${CYAN}$CLI_NAME create MyGame${NC}" + echo -e " ${CYAN}# Create a macOS project${NC}" + echo -e " ${CYAN}$CLI_NAME MyGame${NC}" echo "" echo -e " ${CYAN}# Create iOS project${NC}" - echo -e " ${CYAN}$CLI_NAME create MyGame --platform ios --team-id YOUR_TEAM_ID${NC}" + echo -e " ${CYAN}$CLI_NAME MyGame --platform ios --team-id YOUR_TEAM_ID${NC}" echo "" echo -e " ${CYAN}# Create multi-platform project (macOS, iOS, visionOS)${NC}" - echo -e " ${CYAN}$CLI_NAME create MyGame --platform multi --team-id YOUR_TEAM_ID${NC}" + echo -e " ${CYAN}$CLI_NAME MyGame --platform multi --team-id YOUR_TEAM_ID${NC}" echo "" else echo -e "${YELLOW}⚠️ Warning: $CLI_NAME not found in PATH${NC}"