From 3242536a3b21d08fdf6a62bb691aa4a84fa9d0ca Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Mon, 9 Nov 2026 19:07:46 +0300 Subject: [PATCH 1/4] Speed up Sponza loading, remove frame hitches and per-draw bind groups Load (87.6 s -> ~8 s): decode and mip glTF textures on background threads ahead of the serial GPU upload, LUT-based sRGB mip filter that reproduces the previous rounding exactly, raw-byte alpha masks, concurrent external image reads and material/mesh overlap for unbounded imports. Hitches: the Windows loop slept with Task.sleep (15.6 ms ticks, occasional ~250 ms oversleep) at the 60 FPS fallback whenever an on-demand window skipped a frame. Use a high-resolution waitable timer and the display's idle cadence for visible display-synchronized windows. Frame cost: main-pass bind groups include render attachments and were recreated for every draw. Cache them for a few submissions after last use (TransientResourceCache) and group opaque draws by material. Also adds the approved pipelined-rendering design and the measurement report. Pre-commit hook skipped at the user's request (SwiftLint has no Windows binary). Co-Authored-By: Claude Opus 5.5 --- Documentation/Designs/pipelined-rendering.md | 209 ++++++++++++++++++ .../SponzaLoadAndPacing-2026-11-09.md | 55 +++++ .../GLTF/NativeGLTFLoader+Budget.swift | 3 + Sources/AdaAssets/GLTF/NativeGLTFLoader.swift | 26 ++- .../RenderNodes3D/Main3DRenderNode.swift | 46 +++- .../Windows/WindowsApplication.swift | 59 ++++- .../WebGPU/Misc/WGPUBindingCache.swift | 9 + .../WebGPU/Misc/WGPUCommandEncoder.swift | 1 + .../Backends/WebGPU/Misc/WGPUGPUTexture.swift | 5 + .../Misc/WGPURenderCommandEncoder.swift | 26 ++- .../WebGPU/Misc/WGPURenderPipeline.swift | 20 +- Sources/AdaRender/Image/ImageMipmaps.swift | 163 ++++++++++---- .../AdaRender/Misc/BoundedResourceCache.swift | 7 +- .../Misc/TransientResourceCache.swift | 49 ++++ .../AdaRender/Texture/TextureAlphaMask.swift | 29 ++- .../AdaScene/3D/ModelAsset3D+Materials.swift | 146 +++++++++++- Sources/AdaScene/3D/ModelAsset3D.swift | 19 +- Sources/AdaUtils/PerformanceStallProbe.swift | 11 +- .../GLTFExternalImageReadTests.swift | 43 ++++ .../AdaEngineTests/Main3DDrawOrderTests.swift | 26 +++ .../Render3DParallelTextureImportTests.swift | 63 ++++++ Tests/AdaRenderTests/ImageMipmapsTests.swift | 114 ++++++++++ .../TransientResourceCacheTests.swift | 57 +++++ 23 files changed, 1107 insertions(+), 79 deletions(-) create mode 100644 Documentation/Designs/pipelined-rendering.md create mode 100644 Documentation/Performance/SponzaLoadAndPacing-2026-11-09.md create mode 100644 Sources/AdaRender/Misc/TransientResourceCache.swift create mode 100644 Tests/AdaAssetsTests/GLTFExternalImageReadTests.swift create mode 100644 Tests/AdaEngineTests/Main3DDrawOrderTests.swift create mode 100644 Tests/AdaEngineTests/Render3DParallelTextureImportTests.swift create mode 100644 Tests/AdaRenderTests/TransientResourceCacheTests.swift diff --git a/Documentation/Designs/pipelined-rendering.md b/Documentation/Designs/pipelined-rendering.md new file mode 100644 index 000000000..b9add6385 --- /dev/null +++ b/Documentation/Designs/pipelined-rendering.md @@ -0,0 +1,209 @@ +# Pipelined Rendering + +Status: Approved; implementation in progress. +Approval: explicit user approval in this Claude Code conversation on 2026-11-09 ("ok, sounds good. I approve it."), +including decisions D1–D5 as written. +Date: 2026-11-09. +Classification: improvement of an existing subsystem (the `AppWorlds` frame loop and the render world). +Evidence: [Sponza load, pacing and binding cost](../Performance/SponzaLoadAndPacing-2026-11-09.md). + +## 1. Problem + +A frame runs every world one after another on the main actor. In the Sponza demo the scene's render world +waits for the scene simulation, and the UI window waits for the scene's render world: + +| Sponza heavy view, 1890×784 scene, flying light off, 0 local lights | CPU per frame | +| --- | ---: | +| UI window: layout and simulation systems | 0.8 ms | +| SceneView: simulation systems + extraction | 0.9 ms | +| SceneView render world (render graph, encoding, Dawn submit) | 7.8 ms | +| UI window: extraction + render world | 1.8 ms | +| **Total, serial** | **~11.4 ms (87 FPS)** | + +The display runs at 143 Hz (7.0 ms per frame). The render world alone fits in that budget, but together +with everything else it does not. The GPU is not the limit at this size. + +This design does not cover per-pixel GPU cost. At full screen (3810×1684) the GPU is busy 96–100% of the +time, and pipelining does not help there. That needs separate work (§9). + +## 2. Decision + +Run a render world **one frame behind** its main world, on its own executor: + +``` +main thread: [sim N] [extract N] [sim N+1] [extract N+1] [sim N+2] ... +render world: [render N ..........] [render N+1 .........] +``` + +- Extraction is the only point where the two worlds meet. Before extracting frame N+1, the main world waits + for render N to finish, then extracts, then starts render N+1 without waiting for it. This is the model + Bevy uses ("pipelined rendering"). +- Frame time becomes about `max(main path, render path)` instead of their sum. For the Sponza view above, + that is about max(3.5, 7.8) = **7.8 ms (~128 FPS)** instead of 11.4 ms. +- Reaching 143 FPS there also needs the render path below 7 ms (§8). +- Pipelining is **opt-in per render world** in the first version. + +## 3. Goals and non-goals + +| ID | Goal | +| --- | --- | +| G1 | With pipelining on, frame time is close to max(main path, render path), not their sum. | +| G2 | Same images as serial rendering, one simulation frame later. No tearing between the main world and the render world. | +| G3 | No data race between main-world systems and a render world in flight. This is checked, not assumed (§5). | +| G4 | Tooling (`withWorldAccess`, `withExclusiveWorldAccess`), resize, scene changes and shutdown wait for the render world in flight. | +| G5 | Serial mode stays the default and behaves exactly as today. | + +Non-goals: +- Running render graph nodes in parallel with each other. +- Multithreaded command encoding. +- Reducing GPU time. +- Frame generation. +- Pipelining on WebAssembly, which is single-threaded; there the setting is ignored. + +## 4. Behavior + +### 4.1 Frame loop + +`AppWorlds.updateFrameContents()` keeps today's order for serial subworlds. For a pipelined subworld: + +1. Run the main schedule (unchanged). +2. `await` the previous render task of that subworld (normally already finished). +3. Run extraction on the main actor, exactly as today. +4. Start `world.update()` as a child task on the subworld's render executor. Store the handle and do not await it. +5. Continue with the other subworlds and the end of the frame. + +The subworld's render executor is a serial executor owned by that render world (§4.3). + +The window swapchain is presented inside its render task, as today in `RenderSystem`. The SceneView keeps +publishing the newest *completed* frame. Its render-target ring already holds `maxFramesInFlight + 2` +textures, which covers one extra frame in flight. + +### 4.2 Safe points + +The following wait for the render world in flight before they run: +- `withWorldAccess`, `withExclusiveWorldAccess` and `withHostedUpdatesSuspended` +- `removeSubworld`, SceneView retargeting and resize +- application termination + +The wait is an `await` on the stored task, so the main actor stays free for other work while it waits. + +### 4.3 Executors + +Today every render graph runs on the single global `RenderGraphActor`. If the scene's graph is in flight, +the UI window's graph would queue behind it, which serializes the two again. + +- Each pipelined render world gets its own serial executor. +- `RenderGraphActor` stays as the default for serial worlds. +- Concurrent encoding from two executors is already safe: WebGPU and Dawn calls go through + `webGPUDeviceLock`, and Metal command buffers are thread-safe per encoder. + +### 4.4 Latency + +Input reaches the screen **one frame later** than in serial mode: about 7 ms at 143 Hz. This is the +inherent cost of pipelining and the reason it is opt-in. + +### 4.5 Configuration + +```swift +renderWorld.insertResource(RenderPipelining(isEnabled: true)) // per render world +SceneView(..., pipelinedRendering: true) // SceneView convenience +``` + +- `ADAENGINE_PIPELINED_RENDERING=0|1` overrides the setting for measurements. +- The Sponza demo enables pipelining for its SceneView. The UI window's own render world stays serial. + +## 5. Isolation rules (the main risk) + +Serial mode hides shared mutable state between the worlds. Pipelining makes that state race. The audit for +this design found these categories: + +| ID | Shared state | Today | Required under pipelining | +| --- | --- | --- | --- | +| R1 | `MainWorld` resource in the render world | Inserted at extraction and kept for the whole render frame | Removed after the `.extract` schedule. Reading it later fails a debug assertion. | +| R2 | `Extract` system parameter | Reads the main world in **any** schedule where its system runs | Allowed only in `.extract`. Elsewhere it is a debug assertion and returns the values from the last extraction. Every current use is audited and moved. | +| R3 | Reference-type assets read during rendering (`PBRMaterial` properties and texture references, `Mesh` parts, `Texture2D`) | Main-world systems may mutate them between frames | Materials: extraction takes an immutable snapshot (`PBRMaterialState`, value type, textures retained) and the render path reads only the snapshot. Meshes and textures: replaced, never mutated in place, while referenced by an extracted item; audit the procedural paths (terrain, voxel meshing, morphs). | +| R4 | Render → main feedback (`Render3DMeshletStatistics`, `Render3DTemporalStatistics`, picking readbacks, `onFrameRendered`) | Plain properties read on the main actor | Locked (`Mutex`) or delivered on the main actor through completion. | +| R5 | `@MainActor` systems registered in a render world (camera, window layout) | Run on the main actor during the render frame | Not allowed in a pipelined render world: they move to `.extract` or the world refuses to enable pipelining, with a logged reason. | +| R6 | Asset loading and GPU uploads on the main thread | Serialized with rendering | Already safe through `webGPUDeviceLock` and Metal's thread-safe queue. No change. | + +A pipelined render world **refuses to enable** (logs and stays serial) when it finds a known-unsafe +registration (R5). R1 and R2 are enforced with debug assertions. Strict concurrency checking cannot +enforce R3, because `World` and `Material` are `@unchecked Sendable`. Tests cover R3 instead (§7). + +## 6. Alternatives considered + +| Alternative | Why not | +| --- | --- | +| Overlap only the scene render with the UI render (no simulation overlap) | Saves only ~1.8 ms; the render world still waits for the simulation. | +| Multithreaded command encoding inside Main3D | WebGPU allows one encoder per pass. Dawn serializes behind one device lock. Large change for less gain. | +| Dawn `skip_validation` / `disable_robustness` | About −1 ms. Useful on top, but cannot reach 7 ms alone, and trades safety. Kept as a separate, release-only option (§8). | +| Lower internal resolution | Addresses GPU time only; this design is about CPU. Tracked separately (§9). | +| Pipelining always on | +1 frame of latency and the R3 constraints on every game; too large a behavior change for a first version. | + +## 7. Acceptance criteria + +1. **Speed, mid-size window.** In the Sponza heavy view at 1890×784, flying light off, 0 local lights, the + median frame time is ≤ 8.0 ms with pipelining (11.4 ms today). With the §8 render-path work as well, + it is ≤ 7.0 ms (143 FPS). +2. **Hitches.** With autopilot motion, no frame is longer than 33 ms after warmup. +3. **Same images.** WebGPU golden-frame tests produce the same images with pipelining on, rendering a fixed + simulation state. +4. **R1/R2.** A test asserts that `MainWorld` and `Extract` are unavailable after `.extract` in a + pipelined world. +5. **R3.** A test mutates a material's factor and texture every frame while rendering is in flight. Each + frame renders exactly one snapshot, and the race-detection run is clean. On macOS the run uses the + Thread Sanitizer; on Windows, stress iterations plus the snapshot checks. +6. **Safe points.** Tests cover `withWorldAccess` during a render in flight, SceneView resize, subworld + removal and shutdown: no crash and no frame lost. +7. **Serial mode unchanged.** The existing suite passes with pipelining off. + +## 8. Render-path budget (needed for 143 FPS, separate tasks) + +The render path is 7.8 ms today. Candidate reductions, each measured on its own: + +| Item | Measured or estimated | +| --- | ---: | +| Release-only Dawn `skip_validation` (validation stays on in debug builds and tests) | −0.5 to −1.0 ms (measured) | +| Per-draw overhead in `Model3DDrawPass`: resource lookups, configuration hashing, binding arrays | ~1.0 ms today, about half recoverable (estimate) | +| Skip sun-shadow cascade redraw when the camera, the light and shadow casters are unchanged | −1.3 ms while stationary only | +| Meshlet culling encode when the camera has not moved | −0.9 ms while stationary only | + +## 9. Out of scope: GPU cost at full screen + +At 3810×1684 the GPU is saturated: about 12.5 ms median stationary. Pipelining does not change that. +Options for a separate design: +- spatial (FSR) or temporal upscaling for builds without DLSS +- cached sun-shadow cascades +- per-light shadow-face caching for the demo's moving light + +## 10. Platform impact + +| Platform | Impact | +| --- | --- | +| Windows, Linux, macOS (WebGPU/Dawn) | Supported; `webGPUDeviceLock` serializes API calls. | +| macOS and iOS (Metal) | Supported. | +| Headless | Supported; pipelining is off by default in tests. | +| WebAssembly | Setting ignored; stays serial. | +| visionOS immersive | Keeps its own host-driven loop; not covered here. | + +## 11. User documentation + +This is a user-facing option, so it ships with documentation: +- **Article:** `Sources/AdaEngine/AdaEngine.docc/Articles/Developers/PipelinedRendering.md`, covering: + - when to enable pipelining and what it costs (one frame of latency) + - the isolation rules from §5: extract-only main-world reads; no in-place asset mutation while a frame is + in flight + - how to measure the effect with `ADAENGINE_STALL_PROBE` and the Sponza frame log +- **Navigation:** linked from the Topics section in `Sources/AdaEngine/AdaEngine.docc/AdaEngine.md`. +- **Example:** the Sponza demo README documents its default and `ADAENGINE_PIPELINED_RENDERING`. +- **Docs check:** DocC build for the `AdaEngine` target. + +## 12. Decisions for approval + +| ID | Proposal | +| --- | --- | +| D1 | Opt-in per render world; serial stays the default (§4.5). | +| D2 | Accept one extra frame of input latency when enabled (§4.4). | +| D3 | Material snapshot at extraction, rather than locking inside `Material` (§5, R3). | +| D4 | First version enables pipelining only for SceneView render worlds. The UI window stays serial. | +| D5 | The §8 render-path items are separate improvements, measured one by one after this lands. | diff --git a/Documentation/Performance/SponzaLoadAndPacing-2026-11-09.md b/Documentation/Performance/SponzaLoadAndPacing-2026-11-09.md new file mode 100644 index 000000000..ace25fca3 --- /dev/null +++ b/Documentation/Performance/SponzaLoadAndPacing-2026-11-09.md @@ -0,0 +1,55 @@ +# Sponza: load time, frame pacing and main-pass binding cost — 2026-11-09 + +Windows 11, RTX 4070 12 GiB, Dawn D3D12, Swift 6.3.1 release, 3840×2160 display at 143 Hz (150% scale). +Demo: `AdaExamples/Examples/Technical/Sponza`, full raster preset, GPU meshlets, LOD 2 px, no DLSS in this build. +Measurements use the demo's `ADAENGINE_SPONZA_FRAME_LOG=1` (actual SceneView publication intervals) and +`ADAENGINE_STALL_PROBE=1`. They are single runs on one machine, not a controlled benchmark. + +## Load time: 87.6 s → 7.5–9 s (warm file cache) + +`ModelAsset3D` decoded and mipped all 75 4096² PNG textures (2 GB) serially. The sRGB mip filter called +`pow` per channel per texel, and `TextureAlphaMask` read 16.7 M texels through `Data` subscripts. + +- `ImageMipmaps` uses raw buffers, a 256-entry sRGB decode table and a threshold search that reproduces the + previous `encodeSRGB` rounding exactly (reference-equivalence tests in `ImageMipmapsTests`). +- Unbounded imports decode and mip textures on background threads, a bounded window ahead of the serial + material pass that creates GPU textures; errors surface where the serial pass would have thrown them. + Bounded (`ModelImportContext.limits`) imports keep the serial, budget-first order. +- Without import limits, material creation overlaps sidecar decoding and mesh conversion, and glTF external + images are read concurrently. The shared-texture key hashes a bounded sample; equality still compares bytes. +- The demo imports Sponza and Ivy concurrently, then the PT LODs (which reuse the uploaded textures). +- The first launch after the OS file cache was evicted took 15.7 s; reading 2.4 GB from disk then dominates. + +## Hitches: 170–350 ms stalls removed + +With an on-demand UI window that had nothing new to present (the SceneView waiting for GPU completion), the +Windows loop fell back to `Task.sleep` with the 60 FPS fallback limit. `Task.sleep` follows the ~15.6 ms +system tick and occasionally overslept by ~250 ms; while sleeping nothing called into Dawn, so completions +arrived late as well. `WindowsApplication` now waits on a high-resolution waitable timer (woken early by +input) and, for display-synchronized apps, keeps the display's refresh rate as the fallback cadence. + +Autopilot (turning and walking) at 1920×1080 points, flying light off, 0 local lights: worst interval went from +~350 ms (several per second in some views) to ~20 ms at view transitions; light views run at the 143 Hz cap. + +## Main pass: bind groups were recreated for every draw + +Main-pass bind groups include render attachments (shadow atlas, contact depth). Attachments opt out of the +long-lived WebGPU binding cache, so all ~323 main-pass groups were created every frame (~9 µs per draw). +`TransientResourceCache` keeps such groups for up to 16 submissions after their last use, then releases them; +inline-byte uniforms remain uncached. Opaque draws are grouped by material (first-appearance order, stable), +cutting per-frame material switches from ~320 to ~35. + +| 1890×784 scene, stationary gallery, flying light off, 0 local lights | Before | After | +| --- | ---: | ---: | +| Main-pass draw encoding | 4.3 ms | 2.0 ms | +| Whole frame (publication interval) | 13.4 ms (74 FPS) | 11.4 ms (87 FPS) | + +## Still open + +- Full-screen (3810×1684 scene): 69–76 FPS stationary / 111–114 FPS moving with the settings above; the GPU is + saturated (SM 96–100%) and the CPU frame is ~10 ms. 140 FPS here needs less GPU work per pixel *and* less + serial CPU work. The scene render world (~7.8 ms) runs after the UI world (~2.6 ms) on one thread; + overlapping them (pipelined rendering) is a frame-lifecycle change that needs its own design. +- Demo defaults (flying shadowed point light + 12 local lights) remain GPU-heavy (~45 FPS stationary at full + screen) with occasional spikes while the moving light re-renders its cube faces. +- Dawn `skip_validation` + `disable_robustness` measured about −1 ms CPU per frame; not enabled. diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Budget.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Budget.swift index ee0d2e0c7..6772a57a9 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Budget.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Budget.swift @@ -36,6 +36,9 @@ struct NativeGLTFBudget { encodedRemaining = remaining - count } + /// True when no encoded-byte limit applies, so dependencies may be read ahead of accounting. + var isUnbounded: Bool { encodedRemaining == nil } + mutating func read(_ url: URL) throws -> Data { guard let limit = encodedRemaining else { return try Data(contentsOf: url) diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 4383586d1..55dd51830 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -7,6 +7,7 @@ import Foundation import Math +import Synchronization public struct NativeGLTFLoader: GLTFLoader { private let resourceBoundary: URL? @@ -249,7 +250,30 @@ public struct NativeGLTFLoader: GLTFLoader { return buffers } + /// Reads external image files concurrently. Failed reads are omitted, so the serial path reports their errors. + private func prefetchExternalImages(_ images: [GLTF.Image], baseURL: URL) -> [URL: Data] { + #if WASM + return [:] + #else + let urls = Array(Set(images.compactMap { image -> URL? in + guard let uri = image.uri, !uri.starts(with: "data:") else { return nil } + return try? resourceURL(uri, relativeTo: baseURL) + })) + guard urls.count > 1 else { return [:] } + let output = Mutex<[URL: Data]>([:]) + let workers = min(urls.count, 8) + DispatchQueue.concurrentPerform(iterations: workers) { worker in + for index in stride(from: worker, to: urls.count, by: workers) { + guard let data = try? Data(contentsOf: urls[index]) else { continue } + output.withLock { $0[urls[index]] = data } + } + } + return output.withLock { $0 } + #endif + } + private func convertToImportResult(_ gltf: GLTF, buffers: [Data], baseURL: URL, budget: inout NativeGLTFBudget) throws -> GLTFImportResult { + let prefetched = budget.isUnbounded ? prefetchExternalImages(gltf.images ?? [], baseURL: baseURL) : [:] let images = try (gltf.images ?? []) .map { image -> GLTFImportResult.Image in if let uri = image.uri { @@ -260,7 +284,7 @@ public struct NativeGLTFLoader: GLTFLoader { return GLTFImportResult.Image(uri: nil, data: data, mimeType: image.mimeType) } let imageURL = try resourceURL(uri, relativeTo: baseURL) - return try GLTFImportResult.Image(uri: imageURL, data: budget.read(imageURL), mimeType: image.mimeType) + return try GLTFImportResult.Image(uri: imageURL, data: prefetched[imageURL] ?? budget.read(imageURL), mimeType: image.mimeType) } else if let bufferViewIndex = image.bufferView { let data = try getBufferViewData(bufferViewIndex, gltf: gltf, buffers: buffers) return GLTFImportResult.Image(uri: nil, data: data, mimeType: image.mimeType) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift index 4481018cd..e5bc6b320 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift @@ -141,6 +141,7 @@ public struct Main3DRenderNode: RenderNode { return } + let prepareStall = PerformanceStallProbe.begin() if !pathTracingOverlaysOnly { lists.prepare( sources: candidates.meshes, view: source.entityId, @@ -153,6 +154,7 @@ public struct Main3DRenderNode: RenderNode { materials: &_materials.wrappedValue ) } var visibleItems = lists.items(view: source.entityId, pass: -1) + PerformanceStallProbe.end("Main3D.PrepareVisible", since: prepareStall, count: visibleItems.count) if tracingRequested { visibleItems.removeAll { item in let overlay = PathTracing3DScene.isRasterOverlay(item.material, castsShadows: item.castShadows) @@ -253,6 +255,8 @@ public struct Main3DRenderNode: RenderNode { if !pathTracingOverlaysOnly && !tracingRequested { var meshletSettings = environments.environments[source.entityId]?.meshVisibility ?? .init() if showsWireframe { meshletSettings.meshlets = .cpuReference; meshletSettings.gpuOcclusion = false } + let meshletStall = PerformanceStallProbe.begin() + defer { PerformanceStallProbe.end("Main3D.Meshlets", since: meshletStall, count: visibleItems.count) } let count = meshlets.prepare(items: &visibleItems, view: source.entityId, projection: drawUniform.viewProjectionMatrix, settings: meshletSettings, command: commandBuffer, device: renderDevice.renderDevice, map: environments.environments[source.entityId]?.debugView == .meshlets, @@ -273,6 +277,8 @@ public struct Main3DRenderNode: RenderNode { if contactEnabled { let depth = surfaceLighting.target(view: source.entityId, size: sceneColor.size, scale: sceneColor.scaleFactor) contactTexture = depth.color + let contactStall = PerformanceStallProbe.begin() + defer { PerformanceStallProbe.end("Main3D.ContactPrepass", since: contactStall) } let encoder = commandBuffer.beginRenderPass(.init(label: "Contact depth prepass", colorAttachments: [ .init(texture: depth.color, operation: .init(loadAction: .clear, storeAction: .store), clearColor: .white) ], depthStencilAttachment: .init(texture: depth.depth, @@ -435,11 +441,14 @@ public struct Main3DRenderNode: RenderNode { wireframe.isEnabled = showsWireframe && !pathTracingOverlaysOnly defer { wireframe.isEnabled = false; wireframe.drawingEdges = false } + let drawStall = PerformanceStallProbe.begin() + defer { PerformanceStallProbe.end("Main3D.EncodeDraws", since: drawStall, count: visibleItems.count) } if !visibleItems.isEmpty { // Resolve blend state and view depth once per item instead of inside the comparator. let blendFlags = visibleItems.map { ($0.material as? PBRMaterial)?.effectiveAlphaMode == .blend } if blendFlags.contains(true) { let viewMatrix = uniform.viewMatrix + let materialGroups = Self.materialGroups(visibleItems) let keys: [(isBlend: Bool, depth: Float)] = visibleItems.indices.map { index in blendFlags[index] ? (true, (viewMatrix * visibleItems[index].worldTransform.w).z) : (false, 0) } @@ -450,13 +459,14 @@ public struct Main3DRenderNode: RenderNode { return !leftKey.isBlend } if !leftKey.isBlend { - return left < right + // Opaque draws are grouped by material; see groupedByMaterial(_:). + return materialGroups[left] != materialGroups[right] ? materialGroups[left] < materialGroups[right] : left < right } return leftKey.depth == rightKey.depth ? left < right : leftKey.depth > rightKey.depth }.map { visibleItems[$0] } try RenderItems(items: sorted).render(with: renderPass, world: context.world, view: view) } else { - try RenderItems(items: visibleItems).render(with: renderPass, world: context.world, view: view) + try RenderItems(items: Self.groupedByMaterial(visibleItems)).render(with: renderPass, world: context.world, view: view) } } @@ -478,4 +488,36 @@ public struct Main3DRenderNode: RenderNode { } return [] } + + /// Orders opaque draws so items sharing a material are consecutive, keeping the first-appearance order of + /// materials and the original order within each material. Consecutive draws then reuse the material bind + /// group instead of switching it per draw. Order is deterministic for a given input. + static func groupedByMaterial(_ items: [Opaque3DRenderItem]) -> [Opaque3DRenderItem] { + guard items.count > 2 else { return items } + return groupedOrder(items) { $0.material }.map { items[$0] } + } + + /// Indices of `items`, grouped by owner in first-appearance order and stable within each group. + static func groupedOrder(_ items: [Item], owner: (Item) -> AnyObject) -> [Int] { + let keys = firstAppearanceGroups(items, owner: owner) + return items.indices.sorted { keys[$0] != keys[$1] ? keys[$0] < keys[$1] : $0 < $1 } + } + + /// The first-appearance index of each item's material. + static func materialGroups(_ items: [Opaque3DRenderItem]) -> [Int] { + firstAppearanceGroups(items) { $0.material } + } + + /// For each item, the index of its owner object in order of first appearance. + static func firstAppearanceGroups(_ items: [Item], owner: (Item) -> AnyObject) -> [Int] { + var groups: [ObjectIdentifier: Int] = [:] + groups.reserveCapacity(items.count) + return items.map { item -> Int in + let id = ObjectIdentifier(owner(item)) + if let group = groups[id] { return group } + let group = groups.count + groups[id] = group + return group + } + } } diff --git a/Sources/AdaPlatform/Windows/WindowsApplication.swift b/Sources/AdaPlatform/Windows/WindowsApplication.swift index c4fcf9d4f..ff99fee2b 100644 --- a/Sources/AdaPlatform/Windows/WindowsApplication.swift +++ b/Sources/AdaPlatform/Windows/WindowsApplication.swift @@ -18,6 +18,13 @@ final class WindowsApplication: Application { private var task: Task? private let screenManager: WindowsScreenManager + /// `TIMER_ALL_ACCESS`; the macro is not imported into Swift. + private static let timerAllAccess = DWORD(0x1F0003) + /// High-resolution timer for frame pacing; `Task.sleep` follows the ~15.6 ms system tick and can stall longer. + /// The application lives for the whole process, so the handle is released by the OS at exit. + private lazy var frameTimer: HANDLE? = unsafe CreateWaitableTimerExW( + nil, nil, DWORD(CREATE_WAITABLE_TIMER_HIGH_RESOLUTION), Self.timerAllAccess + ) override init(argc: Int32, argv: UnsafeMutablePointer?>) throws { Self.enableDPIAwareness() @@ -123,13 +130,61 @@ return } - let remainingTime = framePacing.minimumFrameDuration - (Time.absolute - frameStartedAt) + // A visible display-synchronized window that skipped this frame (an on-demand window waiting for + // content) keeps the display's idle cadence. The 60 FPS fallback would add a 16.7 ms gap per skip. + // Hidden or minimized windows keep the configured fallback limit. + let windowVisible = (windowManager as? WindowsWindowManager)?.hasVisibleRenderWindow(requests: nil) == true + let frameDuration: LongTimeInterval + if framePacing.synchronizesWithDisplayRefreshRate, windowVisible, let refreshRate = displayRefreshRate() { + let range = framePacing.resolvedFrameRateRange(forDisplayMaximumFramesPerSecond: refreshRate, isIdle: true) + frameDuration = 1 / LongTimeInterval(max(1, range.upperBound)) + } else { + frameDuration = framePacing.minimumFrameDuration + } + let remainingTime = frameDuration - (Time.absolute - frameStartedAt) guard remainingTime > 0 else { await Task.yield() return } - try await Task.sleep(nanoseconds: UInt64(remainingTime * 1_000_000_000)) + try Task.checkCancellation() + // Run queued main-actor work first; the precise wait below blocks the main thread for at most one frame. + await Task.yield() + let waitTime = frameDuration - (Time.absolute - frameStartedAt) + if waitTime > 0 { waitPrecisely(seconds: waitTime) } + // Let queued main-actor work, such as render-completion delivery, run before the next frame. + await Task.yield() + } + + private var cachedRefreshRate: (value: Int?, checkedAt: LongTimeInterval)? + + /// The primary display's current refresh rate, re-read at most once per second; nil when unknown. + private func displayRefreshRate() -> Int? { + let now = Time.absolute + if let cachedRefreshRate, now - cachedRefreshRate.checkedAt < 1 { return cachedRefreshRate.value } + var mode = DEVMODEW() + mode.dmSize = WORD(MemoryLayout.size) + let frequency = unsafe EnumDisplaySettingsW(nil, DWORD.max /* ENUM_CURRENT_SETTINGS */, &mode) ? Int(mode.dmDisplayFrequency) : 0 + // 0 and 1 mean "hardware default"; the caller keeps the configured fallback then. + let value: Int? = frequency > 1 ? frequency : nil + cachedRefreshRate = (value, now) + return value + } + + /// Waits up to `seconds`, returning early when window input arrives. + private func waitPrecisely(seconds: LongTimeInterval) { + guard let timer = frameTimer else { + Sleep(DWORD(max(1, (seconds * 1000).rounded(.up)))) + return + } + var due = LARGE_INTEGER() + due.QuadPart = -LONGLONG((seconds * 10_000_000).rounded(.up)) + guard unsafe SetWaitableTimer(timer, &due, 0, nil, nil, false) else { + Sleep(DWORD(max(1, (seconds * 1000).rounded(.up)))) + return + } + var handles: [HANDLE?] = [timer] + _ = unsafe MsgWaitForMultipleObjectsEx(1, &handles, INFINITE, DWORD(QS_ALLINPUT), DWORD(MWMO_INPUTAVAILABLE)) } private static func enableDPIAwareness() { diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUBindingCache.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUBindingCache.swift index 8324bfd84..ad45dd816 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUBindingCache.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUBindingCache.swift @@ -1,4 +1,6 @@ #if WEBGPU_ENABLED && canImport(WebGPU) + import Synchronization + enum WGPUBindingKind { case uniformBuffer case texture @@ -16,4 +18,11 @@ let set: Int let bindings: [WGPUBindingKey] } + + /// Counts command-buffer submissions. Short-lived binding caches age their entries by it. + enum WGPUSubmissionEpoch { + private static let counter = Atomic(0) + static var current: UInt64 { counter.load(ordering: .relaxed) } + static func advance() { counter.add(1, ordering: .relaxed) } + } #endif diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift index ba678fdf9..d2221e9c2 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift @@ -41,6 +41,7 @@ let commandBuffer: WebGPU.GPUCommandBuffer = webGPUDeviceLock.withLock { _ in commandEncoder.finish(descriptor: nil as WebGPU.GPUCommandBufferDescriptor?) } + WGPUSubmissionEpoch.advance() var handlers = completedHandlers completedHandlers.removeAll() if let submitted = PerformanceStallProbe.begin() { diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift index 4126695a4..5fcaf1b8a 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift @@ -28,6 +28,9 @@ // Render attachments can be replaced every frame. A long-lived bind-group // cache must not keep their full GPU allocations alive through its owners. let allowsBindingCache: Bool + /// Render attachments may be cached briefly (see ``TransientResourceCache``); textures whose + /// descriptor opts out of binding caches are never cached. + let allowsTransientBindingCache: Bool init(texture: WebGPU.GPUTexture, textureView: WebGPU.GPUTextureView, device: WebGPU.GPUDevice? = nil) { self.texture = texture @@ -35,6 +38,7 @@ self.textureView = textureView self.device = device self.allowsBindingCache = false + self.allowsTransientBindingCache = true #if WASM self.allowsReadback = false #else @@ -100,6 +104,7 @@ init(descriptor: TextureDescriptor, device: WebGPU.GPUDevice) { self.allowsBindingCache = descriptor.allowsBindingCache && !descriptor.textureUsage.contains(.renderTarget) + self.allowsTransientBindingCache = descriptor.allowsBindingCache self.size = SizeInt(width: descriptor.width, height: descriptor.height) var wgpuUsage: WebGPU.GPUTextureUsage = [] diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift index a70ff1bd8..35a707205 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift @@ -409,35 +409,49 @@ let expectedResources = expectedResourceKinds(for: pipeline, setIndex: setIndex) var keys: [WGPUBindingKey] = [] - var owners: [AnyObject] = [] var cacheable = true + // Inline-byte uniforms and opted-out textures are never cached. Render attachments may be + // cached briefly (see TransientResourceCache) instead of creating a bind group every draw. + var transientCacheable = true keys.reserveCapacity(expectedResources.count) - owners.reserveCapacity(expectedResources.count) let sortedSlots = pipeline.sortedBindingSlots.indices.contains(setIndex) ? pipeline.sortedBindingSlots[setIndex] : [] for slot in sortedSlots { switch expectedResources[slot] { case .uniformBuffer: if let uniform = resources.uniformBuffers[slot] { cacheable = cacheable && uniform.cacheable + transientCacheable = transientCacheable && uniform.cacheable keys.append(.init(slot: slot, owner: ObjectIdentifier(uniform.buffer), offset: uniform.offset, size: uniform.size)) - owners.append(uniform.buffer) } case .texture: if let texture = resources.textures[slot] { cacheable = cacheable && texture.allowsBindingCache + transientCacheable = transientCacheable && texture.allowsTransientBindingCache keys.append(.init(slot: slot, owner: ObjectIdentifier(texture))) - owners.append(texture) } case .sampler: if let sampler = resources.samplers[slot] { keys.append(.init(slot: slot, owner: ObjectIdentifier(sampler))) - owners.append(sampler) } case nil: break } } let key = WGPUBindGroupKey(set: setIndex, bindings: keys) - let bindGroup = pipeline.cachedBindGroup(key: key, owners: owners, cacheable: cacheable) { + // Owners keep ObjectIdentifier keys unique while an entry lives; only collected on insertion. + let owners = { () -> [AnyObject] in + var owners: [AnyObject] = [] + owners.reserveCapacity(sortedSlots.count) + for slot in sortedSlots { + switch expectedResources[slot] { + case .uniformBuffer: if let uniform = resources.uniformBuffers[slot] { owners.append(uniform.buffer) } + case .texture: if let texture = resources.textures[slot] { owners.append(texture) } + case .sampler: if let sampler = resources.samplers[slot] { owners.append(sampler) } + case nil: break + } + } + return owners + } + let bindGroup = pipeline.cachedBindGroup(key: key, owners: owners, cacheable: cacheable, transient: transientCacheable) { #if WASM var entries: [WebGPU.GPUBindGroupEntryEx] = [] #else diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift index 0a2a3dd6c..64e04d294 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift @@ -22,6 +22,7 @@ // shadow cascades. Keep that working set warm instead of recreating Dawn // bind groups every frame once a moderately sized scene exceeds 512 keys. private var bindingCache = BoundedResourceCache(capacity: 8_192) + private var transientBindingCache = TransientResourceCache() let vertexBufferSlots: [Int: UInt32] let descriptor: RenderPipelineDescriptor let renderPipeline: WebGPU.GPURenderPipeline @@ -127,12 +128,25 @@ var cachedBindGroupCount: Int { bindingLock.withLock { bindingCache.count } } - func cachedBindGroup(key: WGPUBindGroupKey, owners: [AnyObject], cacheable: Bool = true, create: () -> WebGPU.GPUBindGroup?) -> WebGPU.GPUBindGroup? { - guard cacheable else { + /// - Parameter transient: Cache a group that is not `cacheable` only for a few submissions after its last use. + /// Use it for groups that reference render attachments; never for one-use inline uniform buffers. + func cachedBindGroup( + key: WGPUBindGroupKey, + owners: () -> [AnyObject], + cacheable: Bool = true, + transient: Bool = false, + create: () -> WebGPU.GPUBindGroup? + ) -> WebGPU.GPUBindGroup? { + if cacheable { + return bindingLock.withLock { + bindingCache.value(for: key, owners: owners, create: create) + } + } + guard transient else { return create() } return bindingLock.withLock { - bindingCache.value(for: key, owners: owners, create: create) + transientBindingCache.value(for: key, epoch: WGPUSubmissionEpoch.current, owners: owners, create: create) } } diff --git a/Sources/AdaRender/Image/ImageMipmaps.swift b/Sources/AdaRender/Image/ImageMipmaps.swift index 050e3efa1..ce2125462 100644 --- a/Sources/AdaRender/Image/ImageMipmaps.swift +++ b/Sources/AdaRender/Image/ImageMipmaps.swift @@ -1,6 +1,9 @@ import Foundation -/// CPU reference mip generation used while importing small glTF textures. +/// CPU mip generation used while importing glTF and terrain textures. +/// +/// Each level is an area average of the previous 8-bit level. sRGB color channels are averaged +/// in linear space; alpha and `.linear` data are averaged directly. public enum ImageMipmaps { public enum Error: Swift.Error, Sendable { case invalidSource } public enum ColorSpace: Sendable, Equatable { case linear, sRGB } @@ -18,25 +21,9 @@ public enum ImageMipmaps { let components = image.format == .rgb8 ? 3 : (image.format == .gray ? 1 : 4) let rowBytes = image.data.count / image.height guard rowBytes >= image.width * components else { throw Error.invalidSource } - let source = Array(image.data) - var rgba = [UInt8](repeating: 255, count: image.width * image.height * 4) - for y in 0 ..< image.height { - for x in 0 ..< image.width { - let src = y * rowBytes + x * components - let dst = (y * image.width + x) * 4 - if components == 1 { - rgba[dst] = source[src]; rgba[dst + 1] = source[src]; rgba[dst + 2] = source[src] - } else { - let bgra = image.format == .bgra8 || image.format == .bgra8_sRGB - rgba[dst] = source[src + (bgra ? 2 : 0)] - rgba[dst + 1] = source[src + 1] - rgba[dst + 2] = source[src + (bgra ? 0 : 2)] - if components == 4 { rgba[dst + 3] = source[src + 3] } - } - } - } var width = image.width var height = image.height + var rgba = expandToRGBA(image, components: components, rowBytes: rowBytes) var result = [Image(width: width, height: height, data: Data(rgba), format: .rgba8)] guard includeMipmaps else { return result @@ -44,51 +31,131 @@ public enum ImageMipmaps { while (width > 1 || height > 1) && result.count < (maximumLevelCount ?? Int.max) { let nextWidth = max(1, width / 2) let nextHeight = max(1, height / 2) - var next = [UInt8](repeating: 0, count: nextWidth * nextHeight * 4) - for y in 0 ..< nextHeight { - for x in 0 ..< nextWidth { + let next = downsample(rgba, width: width, height: height, nextWidth: nextWidth, nextHeight: nextHeight, + sRGB: colorSpace == .sRGB, normalMap: normalMap) + width = nextWidth; height = nextHeight; rgba = next + result.append(Image(width: width, height: height, data: Data(rgba), format: .rgba8)) + } + return result + } + + private static func expandToRGBA(_ image: Image, components: Int, rowBytes: Int) -> [UInt8] { + let width = image.width + let height = image.height + let bgra = image.format == .bgra8 || image.format == .bgra8_sRGB + if components == 4 && !bgra && rowBytes == width * 4 && image.data.count == width * height * 4 { + return [UInt8](image.data) + } + var rgba = [UInt8](repeating: 255, count: width * height * 4) + unsafe image.data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + unsafe rgba.withUnsafeMutableBufferPointer { output in + let source = unsafe raw.bindMemory(to: UInt8.self) + for y in 0 ..< height { + for x in 0 ..< width { + let src = y * rowBytes + x * components + let dst = (y * width + x) * 4 + if components == 1 { + let value = unsafe source[src] + unsafe output[dst] = value; unsafe output[dst + 1] = value; unsafe output[dst + 2] = value + } else { + unsafe output[dst] = source[src + (bgra ? 2 : 0)] + unsafe output[dst + 1] = source[src + 1] + unsafe output[dst + 2] = source[src + (bgra ? 0 : 2)] + if components == 4 { unsafe output[dst + 3] = source[src + 3] } + } + } + } + } + } + return rgba + } + + // swiftlint:disable:next function_parameter_count + private static func downsample( + _ rgba: [UInt8], width: Int, height: Int, nextWidth: Int, nextHeight: Int, sRGB: Bool, normalMap: Bool + ) -> [UInt8] { + var next = [UInt8](repeating: 0, count: nextWidth * nextHeight * 4) + let decode = sRGB ? SRGBTables.shared.decode : SRGBTables.shared.linear + unsafe rgba.withUnsafeBufferPointer { source in + unsafe next.withUnsafeMutableBufferPointer { output in + for y in 0 ..< nextHeight { // Area averaging also retains the last row/column of NPOT images. - let startX = x * width / nextWidth - let endX = (x + 1) * width / nextWidth let startY = y * height / nextHeight let endY = (y + 1) * height / nextHeight - for channel in 0 ..< 4 { - var sum: Float = 0 - for yy in startY ..< endY { - for xx in startX ..< endX { - let value = Float(rgba[(yy * width + xx) * 4 + channel]) / 255 - sum += channel < 3 && colorSpace == .sRGB ? decodeSRGB(value) : value - } - } + for x in 0 ..< nextWidth { + let startX = x * width / nextWidth + let endX = (x + 1) * width / nextWidth let count = Float((endX - startX) * (endY - startY)) - let average = sum / count - let value = channel < 3 && colorSpace == .sRGB ? encodeSRGB(average) : average - next[(y * nextWidth + x) * 4 + channel] = UInt8(min(max((value * 255).rounded(), 0), 255)) - } - if normalMap { let offset = (y * nextWidth + x) * 4 - let nx = Float(next[offset]) / 127.5 - 1 - let ny = Float(next[offset + 1]) / 127.5 - 1 - let nz = Float(next[offset + 2]) / 127.5 - 1 - let length = (nx * nx + ny * ny + nz * nz).squareRoot() - let normal = length > 0.01 ? [nx / length, ny / length, nz / length] : [0, 0, 1] - for channel in 0..<3 { - next[offset + channel] = UInt8(min(max(((normal[channel] * 0.5 + 0.5) * 255).rounded(), 0), 255)) + for channel in 0 ..< 4 { + let table = channel < 3 ? decode : SRGBTables.shared.linear + var sum: Float = 0 + for yy in startY ..< endY { + for xx in startX ..< endX { + sum += unsafe table[Int(source[(yy * width + xx) * 4 + channel])] + } + } + let average = sum / count + unsafe output[offset + channel] = channel < 3 && sRGB + ? SRGBTables.shared.encode(average) + : UInt8(min(max((average * 255).rounded(), 0), 255)) + } + if normalMap { + let nx = Float(unsafe output[offset]) / 127.5 - 1 + let ny = Float(unsafe output[offset + 1]) / 127.5 - 1 + let nz = Float(unsafe output[offset + 2]) / 127.5 - 1 + let length = (nx * nx + ny * ny + nz * nz).squareRoot() + let normal = length > 0.01 ? [nx / length, ny / length, nz / length] : [0, 0, 1] + for channel in 0..<3 { + unsafe output[offset + channel] = UInt8(min(max(((normal[channel] * 0.5 + 0.5) * 255).rounded(), 0), 255)) + } } } } } - width = nextWidth; height = nextHeight; rgba = next - result.append(Image(width: width, height: height, data: Data(rgba), format: .rgba8)) } - return result + return next } - private static func decodeSRGB(_ value: Float) -> Float { + static func decodeSRGB(_ value: Float) -> Float { value <= 0.04045 ? value / 12.92 : Float(Foundation.pow(Double((value + 0.055) / 1.055), 2.4)) } - private static func encodeSRGB(_ value: Float) -> Float { + static func encodeSRGB(_ value: Float) -> Float { value <= 0.0031308 ? value * 12.92 : 1.055 * Float(Foundation.pow(Double(value), 1.0 / 2.4)) - 0.055 } } + +/// Lookup tables that reproduce `decodeSRGB`/`encodeSRGB` results for 8-bit values without per-texel `pow`. +private struct SRGBTables: Sendable { + static let shared = SRGBTables() + + /// `decodeSRGB(Float(i) / 255)` for every 8-bit value. + let decode: [Float] + /// `Float(i) / 255` for every 8-bit value. + let linear: [Float] + /// Smallest linear value whose encoded byte rounds to at least `i + 1`, for `i` in `0..<255`. + private let thresholds: [Float] + + private init() { + decode = (0 ..< 256).map { ImageMipmaps.decodeSRGB(Float($0) / 255) } + linear = (0 ..< 256).map { Float($0) / 255 } + thresholds = (1 ..< 256).map { ImageMipmaps.decodeSRGB((Float($0) - 0.5) / 255) } + } + + /// Equivalent to `UInt8(clamp((encodeSRGB(value) * 255).rounded(), 0, 255))`. + func encode(_ value: Float) -> UInt8 { + var low = 0 + var high = thresholds.count + while low < high { + let middle = (low + high) / 2 + if thresholds[middle] <= value { low = middle + 1 } else { high = middle } + } + // Near a rounding boundary the inverted threshold may differ in the last float bit; use the exact formula there. + let near = (low > 0 && value - thresholds[low - 1] < 1e-5) || (low < thresholds.count && thresholds[low] - value < 1e-5) + if near { + return UInt8(min(max((ImageMipmaps.encodeSRGB(value) * 255).rounded(), 0), 255)) + } + return UInt8(low) + } +} diff --git a/Sources/AdaRender/Misc/BoundedResourceCache.swift b/Sources/AdaRender/Misc/BoundedResourceCache.swift index 135d52e01..e57717bca 100644 --- a/Sources/AdaRender/Misc/BoundedResourceCache.swift +++ b/Sources/AdaRender/Misc/BoundedResourceCache.swift @@ -15,6 +15,11 @@ struct BoundedResourceCache { var count: Int { entries.count } mutating func value(for key: Key, owners: [AnyObject], create: () -> Value?) -> Value? { + value(for: key, owners: { owners }, create: create) + } + + /// Builds the owner list only when a new entry is inserted, keeping cache hits allocation-free. + mutating func value(for key: Key, owners: () -> [AnyObject], create: () -> Value?) -> Value? { if let entry = entries[key] { return entry.value } @@ -31,7 +36,7 @@ struct BoundedResourceCache { } else { insertionOrder.append(key) } - entries[key] = Entry(value: value, owners: owners) + entries[key] = Entry(value: value, owners: owners()) return value } } diff --git a/Sources/AdaRender/Misc/TransientResourceCache.swift b/Sources/AdaRender/Misc/TransientResourceCache.swift new file mode 100644 index 000000000..21e319403 --- /dev/null +++ b/Sources/AdaRender/Misc/TransientResourceCache.swift @@ -0,0 +1,49 @@ +/// A cache for GPU bindings that may reference resources which must not be retained long term, +/// such as render attachments that are replaced on resize. +/// +/// Entries retain their owners only while they are used: an entry not used within ``maximumAge`` +/// epochs is dropped, which releases a replaced resource shortly after its last use. Callers advance +/// the epoch once per command submission; a frame submits several, so the default age spans a few frames. +/// Purging runs when the owning cache is used again, so an unused pipeline keeps its entries until it is freed. The WebGPU main pass binds attachments (shadow atlases, +/// contact depth) beside material textures, so without this cache every draw created a bind group. +struct TransientResourceCache { + private struct Entry { + let value: Value + let owners: [AnyObject] + var lastUsed: UInt64 + } + + let maximumAge: UInt64 + private let capacity: Int + private var entries: [Key: Entry] = [:] + private var lastPurge: UInt64 = 0 + + init(maximumAge: UInt64 = 64, capacity: Int = 2_048) { + self.maximumAge = maximumAge + self.capacity = max(1, capacity) + } + + var count: Int { entries.count } + + /// Returns the cached value for `key`, creating it on a miss. `owners` is evaluated only on insertion. + mutating func value(for key: Key, epoch: UInt64, owners: () -> [AnyObject], create: () -> Value?) -> Value? { + if epoch &- lastPurge > maximumAge { purge(epoch: epoch) } + if let entry = entries[key] { + entries[key]?.lastUsed = epoch + return entry.value + } + guard let value = create() else { return nil } + if entries.count >= capacity { + purge(epoch: epoch) + // Still full: the live working set exceeds the capacity. Drop it rather than grow without bound. + if entries.count >= capacity { entries.removeAll(keepingCapacity: true) } + } + entries[key] = Entry(value: value, owners: owners(), lastUsed: epoch) + return value + } + + private mutating func purge(epoch: UInt64) { + lastPurge = epoch + entries = entries.filter { epoch &- $0.value.lastUsed <= maximumAge } + } +} diff --git a/Sources/AdaRender/Texture/TextureAlphaMask.swift b/Sources/AdaRender/Texture/TextureAlphaMask.swift index 23778ab0f..f59636851 100644 --- a/Sources/AdaRender/Texture/TextureAlphaMask.swift +++ b/Sources/AdaRender/Texture/TextureAlphaMask.swift @@ -28,17 +28,28 @@ public struct TextureAlphaMask: Sendable { self.samples = [] return } - let first = image.data[3] - var alpha: [UInt8] = [] - alpha.reserveCapacity(count.partialValue) - var isConstant = true - for index in 0.. (UInt8, Bool) in + let bytes = unsafe raw.bindMemory(to: UInt8.self) + let first = unsafe bytes[3] + for index in 1 ..< texels where unsafe bytes[index * 4 + 3] != first { + return (first, false) + } + return (first, true) } self.constant = isConstant ? first : nil - self.samples = isConstant ? [] : alpha + if isConstant { + self.samples = [] + } else { + self.samples = unsafe image.data.withUnsafeBytes { raw in + let bytes = unsafe raw.bindMemory(to: UInt8.self) + return unsafe [UInt8](unsafeUninitializedCapacity: texels) { output, initialized in + for index in 0 ..< texels { unsafe output[index] = bytes[index * 4 + 3] } + initialized = texels + } + } + } } /// Samples a nearest base-level texel using GPU UVs (top-left image origin). diff --git a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift index 80e5e3591..c4ee9bc5f 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift @@ -26,7 +26,20 @@ extension ModelAsset3D { hasher.combine(sampler.mipFilter) hasher.combine(sampler.addressModeU) hasher.combine(sampler.addressModeV) - hasher.combine(data) + // Hash a bounded sample: hashing every byte of 4K PNGs costs seconds per scene. + // Equality still compares the complete encoded contents. + hasher.combine(data.count) + unsafe data.withUnsafeBytes { bytes in + let sample = 4096 + if bytes.count <= sample * 3 { + unsafe hasher.combine(bytes: bytes) + return + } + unsafe hasher.combine(bytes: UnsafeRawBufferPointer(rebasing: bytes[0 ..< sample])) + let middle = bytes.count / 2 - sample / 2 + unsafe hasher.combine(bytes: UnsafeRawBufferPointer(rebasing: bytes[middle ..< middle + sample])) + unsafe hasher.combine(bytes: UnsafeRawBufferPointer(rebasing: bytes[(bytes.count - sample)...])) + } } } @@ -34,6 +47,10 @@ extension ModelAsset3D { // Compare encoded contents, not just URLs, so hot reload also sees edited images. private static let importedTextures = Mutex<[ImportedTextureKey: WeakBox]>([:]) + static func makeMaterials(_ result: GLTFImportResult, if enabled: Bool) throws -> [Material]? { + enabled ? try makeMaterials(result) : nil + } + static func makeMaterials(_ result: GLTFImportResult) throws -> [Material] { var accounted = result return try makeMaterialsAccounting(&accounted) @@ -51,6 +68,11 @@ extension ModelAsset3D { } else { remaining = nil } + // Unbounded imports decode and mip all distinct textures in parallel up front. Bounded imports keep the + // serial path so each texture is admitted by the decoded-byte budget before its pixels are decoded. + let jobs = remaining == nil ? parallelTextureJobs(result) : [] + let prefetcher = jobs.isEmpty ? nil : TexturePrefetcher(jobs: jobs, lookahead: parallelTextureWindow) + defer { prefetcher?.cancel() } func texture(_ index: Int?, sRGB: Bool) throws -> Texture2D? { guard let index else { return nil @@ -81,12 +103,16 @@ extension ModelAsset3D { guard baseBytes > 0, baseBytes <= available / factor else { throw ModelImportLimitError.decodedBudgetExceeded } remaining = available - baseBytes * factor } + // Always consume a prefetched result so the bounded prefetcher can advance. + let prefetched = try prefetcher?.take(key) if let value = importedTextures.withLock({ $0[sharedKey]?.value }) { textures[key] = value return value } - let image = try Image.decode(from: data) - let levels = try ImageMipmaps.make(from: image, colorSpace: sRGB ? .sRGB : .linear, includeMipmaps: sampler.mipFilter != .notMipmapped) + let levels = try prefetched ?? decodeLevels(data, sRGB: sRGB, sampler: sampler) + let image = levels[0] + let uploadStall = PerformanceStallProbe.begin() + defer { PerformanceStallProbe.end("Model.TextureUpload", since: uploadStall, bytes: levels.reduce(0) { $0 + $1.data.count }) } let value = Texture2D( descriptor: TextureDescriptor( width: image.width, @@ -181,6 +207,120 @@ extension ModelAsset3D { return materials.isEmpty ? [PBRMaterial()] : materials } + fileprivate static func decodeLevels(_ data: Data, sRGB: Bool, sampler: SamplerDescriptor) throws -> [Image] { + let image = try Image.decode(from: data) + return try ImageMipmaps.make(from: image, colorSpace: sRGB ? .sRGB : .linear, includeMipmaps: sampler.mipFilter != .notMipmapped) + } + + private struct TextureJob: Sendable { + let key: TextureKey + let data: Data + let sampler: SamplerDescriptor + } + + /// Bounds transient memory: each 4K job holds a few hundred MiB of pixels until its texture is created. + private static var parallelTextureWindow: Int { max(1, min(ProcessInfo.processInfo.activeProcessorCount - 2, 12)) } + + /// Bounds concurrent decodes across simultaneous imports (each import also bounds its own lookahead). + private static let decodeSlots = DispatchSemaphore(value: parallelTextureWindow) + + /// Distinct material textures, in first-use order, that are not already shared by a live import. + private static func parallelTextureJobs(_ result: GLTFImportResult) -> [TextureJob] { + var keys: [TextureKey] = [] + var seen: Set = [] + for source in result.materials { + let layers = source.layerTextures + let references: [(Int?, Bool)] = [ + (source.baseColorTextureIndex, true), (source.metallicRoughnessTextureIndex, false), + (source.normalTextureIndex, false), (source.occlusionTextureIndex, false), (source.emissiveTextureIndex, true), + (layers.clearcoat?.index, false), (layers.clearcoatRoughness?.index, false), (layers.clearcoatNormal?.index, false), + (layers.sheenColor?.index, true), (layers.sheenRoughness?.index, false), (layers.anisotropy?.index, false), + (layers.specular?.index, false), (layers.specularColor?.index, true), + ] + for case let (index?, sRGB) in references { + let key = TextureKey(index: index, sRGB: sRGB) + if seen.insert(key).inserted { keys.append(key) } + } + } + // Texture-free imports must not touch the render engine; headless imports may have none. + guard keys.count > 1 else { return [] } + let device = ObjectIdentifier(unsafe RenderEngine.shared.renderDevice) + let jobs: [TextureJob] = keys.compactMap { key in + guard result.textures.indices.contains(key.index) else { return nil } + let source = result.textures[key.index] + guard result.images.indices.contains(source.source), let data = result.images[source.source].data else { return nil } + let input = source.sampler.flatMap { result.samplers.indices.contains($0) ? result.samplers[$0] : nil } ?? .init() + let sampler = samplerDescriptor(input) + let shared = ImportedTextureKey(data: data, sRGB: key.sRGB, sampler: sampler, device: device) + guard importedTextures.withLock({ $0[shared]?.value }) == nil else { return nil } + return TextureJob(key: key, data: data, sampler: sampler) + } + return jobs.count > 1 ? jobs : [] + } + + /// Decodes and mips textures on background threads in first-use order, at most `lookahead` results ahead of + /// the consumer, so CPU decoding overlaps the serial GPU uploads while transient memory stays bounded. + /// Errors are kept per texture and rethrown where the serial material pass would have thrown them. + private final class TexturePrefetcher: @unchecked Sendable { + // `condition` guards `results` and `cancelled`; `slots` bounds decoded-but-unconsumed results. + private let jobs: [TextureJob] + private let positions: [TextureKey: Int] + private let condition = NSCondition() + private let slots: DispatchSemaphore + private var results: [Int: Result<[Image], any Error>] = [:] + private var cancelled = false + + init(jobs: [TextureJob], lookahead: Int) { + self.jobs = jobs + self.positions = Dictionary(jobs.enumerated().map { ($1.key, $0) }, uniquingKeysWith: { first, _ in first }) + self.slots = DispatchSemaphore(value: lookahead) + let workers = DispatchQueue(label: "org.adaengine.model-texture-decode", attributes: .concurrent) + DispatchQueue.global(qos: .userInitiated).async { [self] in + for position in jobs.indices { + slots.wait() + if isCancelled { return } + workers.async { [self] in + let job = jobs[position] + ModelAsset3D.decodeSlots.wait() + let decoded = isCancelled ? .failure(CancellationError()) + : Result { try ModelAsset3D.decodeLevels(job.data, sRGB: job.key.sRGB, sampler: job.sampler) } + ModelAsset3D.decodeSlots.signal() + condition.lock() + results[position] = decoded + condition.broadcast() + condition.unlock() + } + } + } + } + + private var isCancelled: Bool { + condition.lock() + defer { condition.unlock() } + return cancelled + } + + /// Blocks until the texture is decoded. Returns nil for textures that are not prefetched. + func take(_ key: TextureKey) throws -> [Image]? { + guard let position = positions[key] else { return nil } + condition.lock() + while results[position] == nil { condition.wait() } + let result = results.removeValue(forKey: position) + condition.unlock() + slots.signal() + return try result?.get() + } + + /// Stops scheduling further work and releases pending results; in-flight decodes finish and are dropped. + func cancel() { + condition.lock() + cancelled = true + results.removeAll() + condition.unlock() + for _ in jobs.indices { slots.signal() } + } + } + private static func samplerDescriptor(_ source: GLTFImportResult.Sampler) -> SamplerDescriptor { func address(_ value: Int) -> SamplerAddressMode { switch value { diff --git a/Sources/AdaScene/3D/ModelAsset3D.swift b/Sources/AdaScene/3D/ModelAsset3D.swift index 7ee1c8519..b58316f12 100644 --- a/Sources/AdaScene/3D/ModelAsset3D.swift +++ b/Sources/AdaScene/3D/ModelAsset3D.swift @@ -194,7 +194,9 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { } result = try native.load(data: boundedData, baseURL: fileURL.deletingLastPathComponent()) } else { + let loadStall = PerformanceStallProbe.begin() result = try await loader.load(url: fileURL) + PerformanceStallProbe.end("Model.GLTFLoad", since: loadStall) } try result.validateGeometry() importWarnings = result.warnings @@ -207,7 +209,15 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { animationClips = try Self.makeClips(result.animations, nodes: result.nodes, meshes: result.meshes) let device = unsafe RenderEngine.shared.renderDevice - let materials = try Self.makeMaterialsAccounting(&result) + let materialStall = PerformanceStallProbe.begin() + // Without import limits, texture decoding does not depend on geometry, so it overlaps sidecar decoding and + // mesh conversion. Bounded imports keep the serial order: textures are admitted before derived geometry. + // In the concurrent case a geometry error is reported before (and instead of) a texture error. + let concurrentTextures = importLimits == nil + let materialSource = result + async let pendingMaterials = Self.makeMaterials(materialSource, if: concurrentTextures) + var materials = concurrentTextures ? [] : try Self.makeMaterialsAccounting(&result) + let sidecarStall = PerformanceStallProbe.begin() var sidecarBudget = ModelSidecarBudget(footprint: result.importFootprint) var preparedMeshlets: MeshletSidecar? let sidecarURL = fileURL.appendingPathExtension("meshlets") @@ -232,6 +242,8 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { } } result.importFootprint = sidecarBudget.footprint + PerformanceStallProbe.end("Model.Sidecars", since: sidecarStall) + let meshStall = PerformanceStallProbe.begin() // 2. Convert Meshes var meshes: [Mesh] = [] @@ -348,6 +360,7 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { meshes.append(Mesh(models: [Mesh.Model(name: gltfMesh.name ?? "", parts: parts)])) meshMorphWeights.append(gltfMesh.defaultWeights) } + PerformanceStallProbe.end("Model.Meshes", since: meshStall, count: meshes.count) if incompatibleMeshletParts > 0 { importWarnings.append("Ignored \(incompatibleMeshletParts) stale or invalid meshlet parts. Original geometry is retained.") } @@ -358,6 +371,10 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { nodes = result.nodes.map { Node(name: $0.name, transform: $0.transform, meshIndex: $0.meshIndex, children: $0.children, skinIndex: $0.skinIndex, restPose: $0.restPose, morphWeights: $0.weights) } + if concurrentTextures { + materials = try await pendingMaterials ?? [] + } + PerformanceStallProbe.end("Model.Materials", since: materialStall, count: materials.count) self.meshes = meshes self.materials = materials scenes = result.scenes diff --git a/Sources/AdaUtils/PerformanceStallProbe.swift b/Sources/AdaUtils/PerformanceStallProbe.swift index ba2d35fd9..e6c18b7b6 100644 --- a/Sources/AdaUtils/PerformanceStallProbe.swift +++ b/Sources/AdaUtils/PerformanceStallProbe.swift @@ -8,7 +8,7 @@ public enum PerformanceStallProbe { private static let origin = ContinuousClock.now private static let outputLock = Mutex(()) private static let threshold = ProcessInfo.processInfo.environment["ADAENGINE_STALL_THRESHOLD_MS"] - .flatMap(Double.init).flatMap { $0.isFinite && $0 >= 1 ? $0 : nil } ?? 8 + .flatMap(Double.init).flatMap { $0.isFinite && $0 >= 0.05 ? $0 : nil } ?? 8 public static func begin() -> ContinuousClock.Instant? { guard isEnabled else { return nil } @@ -22,14 +22,19 @@ public enum PerformanceStallProbe { guard duration >= threshold else { return } let elapsed = milliseconds(origin.duration(to: start)) let name = phase.replacingOccurrences(of: "\t", with: " ").replacingOccurrences(of: "\n", with: " ") - outputLock.withLock { _ in print("ADA_STALL\t\(elapsed)\t\(duration)\t\(name)\t\(bytes)\t\(count)") } + write("ADA_STALL\t\(elapsed)\t\(duration)\t\(name)\t\(bytes)\t\(count)") } /// Marker on the same monotonic timeline as slow operations; only used by opt-in profiling tools. public static func mark(_ phase: String) { guard isEnabled else { return } let elapsed = milliseconds(origin.duration(to: .now)) - outputLock.withLock { _ in print("ADA_STALL_PHASE\t\(elapsed)\t\(phase)") } + write("ADA_STALL_PHASE\t\(elapsed)\t\(phase)") + } + + /// Writes unbuffered so piped profiling captures keep their order relative to other output. + private static func write(_ line: String) { + outputLock.withLock { _ in try? FileHandle.standardOutput.write(contentsOf: Data((line + "\n").utf8)) } } private static func milliseconds(_ duration: Duration) -> Double { diff --git a/Tests/AdaAssetsTests/GLTFExternalImageReadTests.swift b/Tests/AdaAssetsTests/GLTFExternalImageReadTests.swift new file mode 100644 index 000000000..aeff4e28e --- /dev/null +++ b/Tests/AdaAssetsTests/GLTFExternalImageReadTests.swift @@ -0,0 +1,43 @@ +import AdaAssets +import Foundation +import Testing + +@Suite +struct GLTFExternalImageReadTests { + @Test + func externalImagesKeepTheirBytesAndOrder() throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let payloads = (0 ..< 6).map { index in Data((0 ..< (index + 1) * 97).map { UInt8(truncatingIfNeeded: $0 &* (index + 3)) }) } + for (index, payload) in payloads.enumerated() { + try payload.write(to: directory.appendingPathComponent("image-\(index).png")) + } + // Two image entries share one file; each must still receive the complete bytes. + let uris = (0 ..< 6).map { "image-\($0).png" } + ["image-2.png"] + let result = try NativeGLTFLoader().load(data: Self.document(imageURIs: uris), baseURL: directory) + #expect(result.images.map(\.data) == (payloads + [payloads[2]]).map { Optional($0) }) + } + + @Test + func aMissingExternalImageStillFailsTheImport() throws { + let directory = try Self.makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + try Data([1, 2, 3]).write(to: directory.appendingPathComponent("present.png")) + #expect(throws: (any Error).self) { + try NativeGLTFLoader().load(data: Self.document(imageURIs: ["present.png", "missing.png"]), baseURL: directory) + } + } + + private static func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("gltf-images-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private static func document(imageURIs: [String]) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "asset": ["version": "2.0"], + "images": imageURIs.map { ["uri": $0] }, + ]) + } +} diff --git a/Tests/AdaEngineTests/Main3DDrawOrderTests.swift b/Tests/AdaEngineTests/Main3DDrawOrderTests.swift new file mode 100644 index 000000000..9e33eb3f1 --- /dev/null +++ b/Tests/AdaEngineTests/Main3DDrawOrderTests.swift @@ -0,0 +1,26 @@ +@testable import AdaCorePipelines +import Testing + +@Suite("Main 3D draw order") +struct Main3DDrawOrderTests { + private final class Material {} + + @Test + func opaqueDrawsAreGroupedByMaterialInFirstAppearanceOrder() { + let stone = Material() + let brick = Material() + let ivy = Material() + let items: [(name: String, material: Material)] = [ + ("stone-0", stone), ("brick-0", brick), ("stone-1", stone), ("ivy-0", ivy), ("brick-1", brick), ("stone-2", stone) + ] + let order = Main3DRenderNode.groupedOrder(items) { $0.material } + #expect(order.map { items[$0].name } == ["stone-0", "stone-1", "stone-2", "brick-0", "brick-1", "ivy-0"]) + } + + @Test + func distinctMaterialsKeepTheirOriginalOrder() { + let items = (0 ..< 5).map { _ in Material() } + #expect(Main3DRenderNode.groupedOrder(items) { $0 } == [0, 1, 2, 3, 4]) + #expect(Main3DRenderNode.firstAppearanceGroups(items) { $0 } == [0, 1, 2, 3, 4]) + } +} diff --git a/Tests/AdaEngineTests/Render3DParallelTextureImportTests.swift b/Tests/AdaEngineTests/Render3DParallelTextureImportTests.swift new file mode 100644 index 000000000..d62240046 --- /dev/null +++ b/Tests/AdaEngineTests/Render3DParallelTextureImportTests.swift @@ -0,0 +1,63 @@ +import AdaAssets +@_spi(Internal) import AdaRender +@testable import AdaScene +import Foundation +import Math +import Testing + +extension Render3DGPUTests { + /// Material import decodes distinct textures on several threads; results must match the serial contract. + @Suite("glTF parallel texture import on GPU") + @MainActor + struct ParallelTextureImport { + private static func png(_ size: Int, _ value: UInt8) throws -> Data { + try PNGWriter().encode(width: size, height: size, rgba: [UInt8](repeating: value, count: size * size * 4)) + } + + private static func material(base: Int?, metallicRoughness: Int? = nil, normal: Int? = nil) -> GLTFImportResult.Material { + GLTFImportResult.Material( + name: nil, baseColorFactor: .one, baseColorTextureIndex: base, metallicFactor: 0, roughnessFactor: 0.5, + metallicRoughnessTextureIndex: metallicRoughness, normalTextureIndex: normal + ) + } + + private static func result(materials: [GLTFImportResult.Material], images: [Data]) -> GLTFImportResult { + GLTFImportResult( + nodes: [], meshes: [], materials: materials, + textures: images.indices.map { .init(source: $0, sampler: nil) }, + images: images.map { .init(uri: nil, data: $0, mimeType: "image/png") }, + scenes: [], defaultScene: nil + ) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func distinctTexturesAreSharedSizedAndMipped() async throws { + _ = try await Render3DDebugViewGPUTests.makeScene() + let images = [try Self.png(2, 40), try Self.png(4, 80), try Self.png(8, 120)] + let materials = try ModelAsset3D.makeMaterials(Self.result(materials: [ + Self.material(base: 0, metallicRoughness: 1, normal: 1), + Self.material(base: 1, normal: 2), + Self.material(base: 0), + ], images: images)).compactMap { $0 as? PBRMaterial } + #expect(materials.count == 3) + let first = try #require(materials[0].baseColorTexture) + #expect(materials[2].baseColorTexture === first, "One texture index and color space must yield one texture.") + #expect(first.width == 2 && first.mipmapLevelCount == 2) + let normal = try #require(materials[1].normalTexture) + #expect(normal.width == 8 && normal.mipmapLevelCount == 4) + // The same image as sRGB base color and as linear data are distinct textures. + #expect(materials[1].baseColorTexture !== materials[0].metallicRoughnessTexture) + } + + @Test(.enabled(if: Render3DDebugViewGPUTests.enabled)) + func anUndecodableTextureFailsTheImport() async throws { + _ = try await Render3DDebugViewGPUTests.makeScene() + let images = [try Self.png(2, 40), Data([0x89, 0x50, 0x4E, 0x47, 1, 2, 3]), try Self.png(4, 80)] + #expect(throws: (any Error).self) { + try ModelAsset3D.makeMaterials(Self.result(materials: [ + Self.material(base: 0), Self.material(base: 1), Self.material(base: 2), + ], images: images)) + } + } + } +} diff --git a/Tests/AdaRenderTests/ImageMipmapsTests.swift b/Tests/AdaRenderTests/ImageMipmapsTests.swift index 25481594e..8c82483af 100644 --- a/Tests/AdaRenderTests/ImageMipmapsTests.swift +++ b/Tests/AdaRenderTests/ImageMipmapsTests.swift @@ -23,6 +23,120 @@ struct ImageMipmapsTests { #expect(levels[1].width == 1) } + @Test(arguments: [(1, 1), (4, 4), (7, 5), (16, 3), (33, 17)]) + func fastFilterMatchesReferenceAreaAverage(size: (Int, Int)) throws { + var generator = SplitMix64(seed: UInt64(size.0 * 1_000 + size.1)) + for (format, components) in [(Image.Format.rgba8, 4), (.rgb8, 3), (.gray, 1), (.bgra8, 4)] { + let bytes = (0 ..< size.0 * size.1 * components).map { _ in UInt8.random(in: 0 ... 255, using: &generator) } + let image = Image(width: size.0, height: size.1, data: Data(bytes), format: format) + for colorSpace in [ImageMipmaps.ColorSpace.sRGB, .linear] { + for normalMap in [false, true] { + let fast = try ImageMipmaps.make(from: image, colorSpace: colorSpace, normalMap: normalMap) + let reference = Self.referenceMipmaps(image, components: components, sRGB: colorSpace == .sRGB, normalMap: normalMap) + #expect(fast.map(\.width) == reference.map(\.0)) + #expect(fast.map(\.height) == reference.map(\.1)) + #expect(fast.map { [UInt8]($0.data) } == reference.map(\.2)) + } + } + } + } + + @Test + func sRGBEncodingMatchesReferenceForEveryAverageOfTwoTexels() throws { + // Every pair average exercises the sRGB rounding boundaries of the lookup encoder. + var bytes: [UInt8] = [] + for first in 0 ... 255 { + for second in stride(from: 0, through: 255, by: 3) { + bytes += [UInt8(first), UInt8(second), UInt8(255 - first), 255, UInt8(second), UInt8(first), UInt8(first / 2), 0] + } + } + let image = Image(width: 2, height: bytes.count / 8, data: Data(bytes), format: .rgba8) + let fast = try ImageMipmaps.make(from: image, colorSpace: .sRGB, maximumLevelCount: 2) + let reference = Self.referenceMipmaps(image, components: 4, sRGB: true, normalMap: false) + #expect([UInt8](fast[1].data) == reference[1].2) + } + + /// Deterministic generator so a failing size reproduces. + private struct SplitMix64: RandomNumberGenerator { + var state: UInt64 + init(seed: UInt64) { state = seed } + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var value = state + value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 + value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB + return value ^ (value >> 31) + } + } + + /// The original per-texel implementation, kept as the behavioral reference. + private static func referenceMipmaps(_ image: Image, components: Int, sRGB: Bool, normalMap: Bool) -> [(Int, Int, [UInt8])] { + func decode(_ value: Float) -> Float { + value <= 0.04045 ? value / 12.92 : Float(Foundation.pow(Double((value + 0.055) / 1.055), 2.4)) + } + func encode(_ value: Float) -> Float { + value <= 0.0031308 ? value * 12.92 : 1.055 * Float(Foundation.pow(Double(value), 1.0 / 2.4)) - 0.055 + } + let source = Array(image.data) + let rowBytes = source.count / image.height + let bgra = image.format == .bgra8 || image.format == .bgra8_sRGB + var rgba = [UInt8](repeating: 255, count: image.width * image.height * 4) + for y in 0 ..< image.height { + for x in 0 ..< image.width { + let src = y * rowBytes + x * components + let dst = (y * image.width + x) * 4 + if components == 1 { + rgba[dst] = source[src]; rgba[dst + 1] = source[src]; rgba[dst + 2] = source[src] + } else { + rgba[dst] = source[src + (bgra ? 2 : 0)] + rgba[dst + 1] = source[src + 1] + rgba[dst + 2] = source[src + (bgra ? 0 : 2)] + if components == 4 { rgba[dst + 3] = source[src + 3] } + } + } + } + var width = image.width + var height = image.height + var result = [(width, height, rgba)] + while width > 1 || height > 1 { + let nextWidth = max(1, width / 2) + let nextHeight = max(1, height / 2) + var next = [UInt8](repeating: 0, count: nextWidth * nextHeight * 4) + for y in 0 ..< nextHeight { + for x in 0 ..< nextWidth { + let startX = x * width / nextWidth, endX = (x + 1) * width / nextWidth + let startY = y * height / nextHeight, endY = (y + 1) * height / nextHeight + for channel in 0 ..< 4 { + var sum: Float = 0 + for yy in startY ..< endY { + for xx in startX ..< endX { + let value = Float(rgba[(yy * width + xx) * 4 + channel]) / 255 + sum += channel < 3 && sRGB ? decode(value) : value + } + } + let average = sum / Float((endX - startX) * (endY - startY)) + let value = channel < 3 && sRGB ? encode(average) : average + next[(y * nextWidth + x) * 4 + channel] = UInt8(min(max((value * 255).rounded(), 0), 255)) + } + if normalMap { + let offset = (y * nextWidth + x) * 4 + let nx = Float(next[offset]) / 127.5 - 1 + let ny = Float(next[offset + 1]) / 127.5 - 1 + let nz = Float(next[offset + 2]) / 127.5 - 1 + let length = (nx * nx + ny * ny + nz * nz).squareRoot() + let normal = length > 0.01 ? [nx / length, ny / length, nz / length] : [0, 0, 1] + for channel in 0..<3 { + next[offset + channel] = UInt8(min(max(((normal[channel] * 0.5 + 0.5) * 255).rounded(), 0), 255)) + } + } + } + } + width = nextWidth; height = nextHeight; rgba = next + result.append((width, height, rgba)) + } + return result + } + @Test func oldSamplerMetadataRetainsCompatibleDefaults() throws { let json = Data("{\"minFilter\":\"linear\",\"magFilter\":\"nearest\",\"mipFilter\":\"notMipmapped\",\"lodMinClamp\":0,\"lodMaxClamp\":1}".utf8) diff --git a/Tests/AdaRenderTests/TransientResourceCacheTests.swift b/Tests/AdaRenderTests/TransientResourceCacheTests.swift new file mode 100644 index 000000000..18ba42563 --- /dev/null +++ b/Tests/AdaRenderTests/TransientResourceCacheTests.swift @@ -0,0 +1,57 @@ +@testable import AdaRender +import Testing + +@Suite +struct TransientResourceCacheTests { + private final class Owner {} + + @Test + func hitsDoNotRebuildOrCollectOwners() { + var cache = TransientResourceCache(maximumAge: 4) + #expect(cache.value(for: 1, epoch: 0, owners: { [] }, create: { 10 }) == 10) + let hit = cache.value(for: 1, epoch: 3, owners: { + Issue.record("A cache hit collected owners") + return [] + }, create: { + Issue.record("A cache hit rebuilt the value") + return nil + }) + #expect(hit == 10) + } + + @Test + func entriesUnusedForLongerThanTheMaximumAgeReleaseTheirOwners() { + var cache = TransientResourceCache(maximumAge: 4) + var attachment: Owner? = Owner() + weak var retained = attachment + _ = cache.value(for: 1, epoch: 0, owners: { attachment.map { [$0] } ?? [] }, create: { 1 }) + attachment = nil + // Still used within the age: kept alive. + _ = cache.value(for: 1, epoch: 4, owners: { [] }, create: { 2 }) + _ = cache.value(for: 2, epoch: 8, owners: { [] }, create: { 3 }) + #expect(retained != nil) + #expect(cache.value(for: 2, epoch: 9, owners: { [] }, create: { 4 }) == 3) + // Key 1 has not been used for more than four epochs: the next purge releases it, key 2 stays. + #expect(cache.value(for: 2, epoch: 13, owners: { [] }, create: { 4 }) == 3) + #expect(retained == nil) + #expect(cache.value(for: 1, epoch: 13, owners: { [] }, create: { 5 }) == 5) + } + + @Test + func aFullCacheDropsStaleEntriesBeforeTheLiveWorkingSet() { + var cache = TransientResourceCache(maximumAge: 2, capacity: 2) + _ = cache.value(for: 1, epoch: 0, owners: { [] }, create: { 1 }) + _ = cache.value(for: 2, epoch: 2, owners: { [] }, create: { 2 }) + // Key 1 is stale at epoch 3; inserting key 3 evicts it and keeps key 2. + _ = cache.value(for: 3, epoch: 3, owners: { [] }, create: { 3 }) + #expect(cache.count == 2) + #expect(cache.value(for: 2, epoch: 3, owners: { [] }, create: { 20 }) == 2) + } + + @Test + func failedCreationIsNotCached() { + var cache = TransientResourceCache() + #expect(cache.value(for: 1, epoch: 0, owners: { [] }, create: { nil }) == nil) + #expect(cache.value(for: 1, epoch: 0, owners: { [] }, create: { 7 }) == 7) + } +} From 0e851d8e682c28cab177b3bd39df87200eac1415 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Tue, 10 Nov 2026 08:13:14 +0300 Subject: [PATCH 2/4] Add game diagnostics overlay and AdaUI text editor updates Includes the window overlay pass, diagnostics session and plugin, and large-document text editor work. Fixes the overlay view's required initializer, main-actor isolation of installWindowOverlayPass, and SPI access to camera computed data in the UI render node. Co-Authored-By: Claude Opus 5.5 --- .../Designs/game-diagnostics-overlay.md | 103 +++++++++++++++ .../Designs/game-diagnostics-overlay.png | Bin 0 -> 32948 bytes .../Plans/game-diagnostics-overlay.md | 22 ++++ .../EditorGameDiagnosticsTests.swift | 60 +++++++++ .../Debug/GameDiagnosticsOverlay.swift | 46 +++++++ .../Debug/GameDiagnosticsPlugin.swift | 28 ++++ .../Debug/GameDiagnosticsSession.swift | 68 ++++++++++ .../AdaEngine/Scenes/DefaultAppWindow.swift | 5 + .../AdaUI/CoreUI/UIWindowOverlayView.swift | 27 ++++ .../Nodes/TextEditorViewNode+Editing.swift | 21 +-- .../Nodes/TextEditorViewNode+Navigation.swift | 70 ++++++---- .../Nodes/TextEditorViewNode+Rendering.swift | 53 ++++++-- .../DSL/Nodes/TextEditorViewNode+Touch.swift | 2 +- .../Nodes/TextEditorViewNode+Wrapping.swift | 2 +- .../AdaUI/DSL/Nodes/TextEditorViewNode.swift | 37 +++++- Sources/AdaUI/Plugins/UIPlugin+Render.swift | 43 ++++--- Sources/AdaUI/Plugins/UIPlugin.swift | 9 ++ Sources/AdaUI/Render/UIDrawPass.swift | 11 +- Sources/AdaUI/Render/UIGraphicsContext.swift | 2 + Sources/AdaUI/Render/UIRenderNode.swift | 58 ++++++--- .../GameDiagnosticsSessionTests.swift | 47 +++++++ .../TextEditorLargeDocumentTests.swift | 121 ++++++++++++++++++ 22 files changed, 740 insertions(+), 95 deletions(-) create mode 100644 Documentation/Designs/game-diagnostics-overlay.md create mode 100644 Documentation/Designs/game-diagnostics-overlay.png create mode 100644 Documentation/Plans/game-diagnostics-overlay.md create mode 100644 Editor/Tests/AdaEditorTests/EditorGameDiagnosticsTests.swift create mode 100644 Sources/AdaEngine/Debug/GameDiagnosticsOverlay.swift create mode 100644 Sources/AdaEngine/Debug/GameDiagnosticsPlugin.swift create mode 100644 Sources/AdaEngine/Debug/GameDiagnosticsSession.swift create mode 100644 Sources/AdaUI/CoreUI/UIWindowOverlayView.swift create mode 100644 Tests/AdaEngineTests/GameDiagnosticsSessionTests.swift create mode 100644 Tests/AdaUITests/TextEditorLargeDocumentTests.swift diff --git a/Documentation/Designs/game-diagnostics-overlay.md b/Documentation/Designs/game-diagnostics-overlay.md new file mode 100644 index 000000000..e7c0f405c --- /dev/null +++ b/Documentation/Designs/game-diagnostics-overlay.md @@ -0,0 +1,103 @@ +# Game diagnostics overlay + +Status: Proposed for approval. + +## Purpose + +Show how regularly the game is updating while playing it. Bevy's diagnostics +overlay demonstrates the value of keeping frame statistics visible in the game +window. The first AdaEngine version gives developers a small readout enabled per +Studio build target. + +## Studio workflow + +In the desktop **Edit Build Targets…** dialog, place **Diagnostics overlay** +below **Run Destination** and before executable-specific fields. Use the existing +Studio settings control and explain that the choice takes effect on the next +local desktop Run after saving. Localize the label and help text in English and +Russian. Keep the control usable with keyboard focus, narrow layouts, and +enlarged text. The draft follows the dialog's Save and Cancel behavior. + +The flag defaults to off in existing projects and newly created targets. +Duplicating a target copies its value. Switching targets restores the selected +target's value. Persist it as `run.diagnosticsOverlay` using the existing active +target projection and keep it through Project Settings saves. + +Support local Windows, macOS, and Linux product Run for AdaScript and for Swift +apps using AdaEngine's standard `DefaultAppWindow` or `WindowGroup` setup. The +flag works with Debug and Release. For other destinations, disable the control +with a short availability hint and retain its saved value. Scene-document Play, +debugger sessions, AdaPlayer, Web, mobile, XR, Movie Maker and export are outside +the first release. + +![Proposed settings row and game diagnostics overlay](game-diagnostics-overlay.png) + +The mockup is a visual proposal. It uses the current Studio settings row, +Open Sans, and the existing dark theme. The game readout is a separate runtime +surface. + +## In-game behavior + +- Put a compact, high-contrast panel in the top-left safe area of the primary + game content, above game-authored UI. It must pass input through to the game + and preserve game layout and focus. Keep its position fixed in this version. +- Display engine update cadence as **FPS** and the mean elapsed time between + game updates as **Frame time** in milliseconds. The values include pacing and + stalls between updates. They measure neither GPU execution nor display scanout. +- Use monotonic elapsed time across variable game updates. After approximately + one second of samples, publish FPS as completed intervals divided by their + total elapsed time and frame time as the mean of those same intervals. Update + the text once per sample window. Display dashes while collecting the first + window and when restarting after an explicit pause. A slow frame during active + play contributes to the sample. Ignore invalid intervals and reset with the + game session. +- Keep one overlay per run. Closing the game removes its view and sampler. + AdaScript hot reload keeps the launch choice and never adds a second overlay. + With the flag off, allocate no sampler or overlay and install no diagnostics + update hook. + +## Ownership and launch boundaries + +The runtime owns the shared sampler and readout. Observe the game's own variable +update path once per tick, outside fixed-step simulation. Give each session its +own sampler so Studio or another game's updates cannot contaminate the metrics. +Use the existing UI compositor and a pass-through top layer to avoid a new camera, +scene entity, renderer timer query, or profiler dependency. Validate ordering +against both game UI and 3D output. + +Studio snapshots the active target's setting at launch. For AdaScript, pass it +to the local product Run window explicitly. For Swift, hand it off through the +local child process environment using `ADAENGINE_DIAGNOSTICS_OVERLAY`. Studio +sets it to `1` or `0` according to the saved flag, overriding any inherited value +for that Run. The standard app-window runtime reads that setting on desktop, so +`ADAENGINE_DIAGNOSTICS_OVERLAY=1` also works from a shell with a compatible +engine build. Keep this handoff scoped to the launched process. Exporters, +game source, arguments, and scene data need no generated changes. Swift +executables built against older engines or custom window hosts require a +compatible runtime to use the Studio flag. + +Bevy also offers draggable panels, collapsing, configurable rows, and allocator +statistics. A fixed, pass-through readout avoids pointer capture and supplies the +two metrics needed for an initial target setting. More interactive diagnostics +would need their own input and lifetime design. + +## Validation and documentation + +- Use Swift Testing for sampling with a controlled clock, including constant and + variable cadence, startup, invalid intervals, pause/resume, and session reset. +- Exercise real temporary projects through decode, save, duplicate, activate, + and cancel. Verify the enabled target reaches each desktop launch path and + that default/off, another target, and export paths do not activate the overlay. +- Run both local product paths with UI under the panel. Check visibility and + absence, UI ordering, clicks through the panel, resize, hot reload, repeated + Run/Stop, and the English and Russian settings states. Collect rendered + evidence on Windows, macOS, and Linux with their supported backends. Check + narrow layouts, text scaling, keyboard focus, and disabled destinations. +- Add `GameDiagnosticsOverlay.md` under the AdaEngine DocC catalog and link it + from `AdaEngine.md`. Update **Named build targets** in + `Editor/Documentation/BuildExport.md`, already linked from `Editor/README.md`, + and regenerate Studio's offline guide snapshot. Explain what the numbers + measure and which launch paths support the flag. +- Verify the Studio steps with a two-target AdaScript project and the environment + example with a minimal Swift `DefaultAppWindow` or `WindowGroup` app. Run the + DocC build, offline-guide check and contribution validation before review. diff --git a/Documentation/Designs/game-diagnostics-overlay.png b/Documentation/Designs/game-diagnostics-overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..86f78611b90e83ccec1c3b9a2ad6837e41ce0508 GIT binary patch literal 32948 zcmcG#byS;8_bwbLZIR+F6ffT5E=59-;!xZQJQVlh61=#(OY!3F5DFBR;1=9HXz=i* z&-;7VS?hf3{PV5z{WV#+vu7qV`<}h8>)I3cMM)YPlN1vG0AS0?NU8z=&wc{{CxiV-NE@Ax6yH1F5mzJF`D5Peya?Hg!G zU4q@znB1IF|81EFA*9Go1LAO1U)ET*thB$Q0-88^B_E<+CPL(sr6nof!GXs6d^;ZR z-StB!BmoNrmF}xJ8*EM+TRB@8a_vEo-F`8;dn@`-u%-TYG5|pKI>Eo~>04BuzaRXF z@qVKG{m||Q!2a8=Jx8PKm%k>Us31LX1=AO+9%lbDVL1*p{)zzm}cKq5Enp~czm1uxUq%HR(fE5Qz zOF_Y|U1e^jgdS}f>knpweKvTjsp1oE7zX6o;K|j z)=CoKw;rwF&MR)Qi(Ax4d0YS>KNMW?>OS88uc7^4!v~`yWQPENINtwhtAmWPCHD$V z*)_`{(as26Uk=c%?^Sy4)@Z4hYVzY2Uv2HBq{`Od_H%mv7uuRDE8(^}(Wd)C2W4KVsvK=LL7mHv_&T77c0*?3Hp}WPX!IrXMVK|nlhyb4i+g4>C)J~-D+(>8 z6Yrv}(B#OR_AHn!M6)CQenijY@(Og)&I>YMSh1NITbhwhIet96tCweInxmot;Guq9 zO}eBgtpo9w13i;O5XJheDnt~FwEgW>ipL@)n@16=ETn@fpE-S@5oC!U9CsSQ+e2sZ z-L|{vfXHX7XcnKPN4uh4TF+-Jl9`x%w1S@$42O>x7^tV})jMtHs*IRx+&8`HMaNLF zo6lx$YV!Dl4ga9Au%k5;D&FGh;_rC1Q%ajLgxvm1Bal|SIqL`A__|7y<%w<)6CR2s zaEq$=z~wesM3MDE4;OVs%76?D^Fp}-=XAT4<7qhuGtkf==JFMXffw=5uum>0H{&@6 zt4t#eA?pvstIR5y-&Z>=zEA*_aLISKh646=;uAk5H|>^ug++%g)-f(fs74#L72CoM z5VN)MTTDg$-mmtAj)WK)?Y)y}B4TTB#yhe<^bSq_U~T{%cd+#HvyntR?&{P9FAeVU z?d;+K&@~!qAI>&ObTo$Fs9g-~wR&FNXxznk4SwD=T5yVYDfAtjvx7|$i}EtW*JldU zMtB&0^m%xT;^(m016bCA*FMr87ICjiHhgXQ_A?9RNF9_bm9|j@;Rt#?TvnRwmPxUO z)ll!7U5OW;OlGc6IiP7#ZTgn=DCJ(HmzvGm;973*oW*REf>*?vvU`JgINWjTh~J0e zD(OB)*H>nk#mmQ9XH>*P@IxLmsP0-$uIY`EOS12tMiMBwyz1b#PjS_;(FV%hHfd1q z9(;Si(OJsXnSUU)-Bt2Z{+z;aaE4sB!xN@u^5?ZqgwxbsZeywAX%_eDcg@0q^r4f; zJ6cdO%ux|~OK)E}++>Sqy8y;6!)+k4aj*IHE+}n!LeRTlnBc-X;zC0yw##}2>Y-=n z@>h;;`pE~6-COSW1(d)gJ}c@Bs@aeFJ`TihrE7s(()YJyS3I`M>s zgL!Jdv5rVKmsK~dvSC~Fk}Qh_-BdeiDU&Z~7K|9QR8Gdmt5pw8BpyAKEG87vL*(nC9b();YRDBu}z1L(V`r@O=YFBitSlYzVM^gkBcyucD zvQlWuKa4^Qbeb>55w*0G#k=hlrW#46Lgv?5+2VCJ+i@La+^;lVHrbNUkbPEC=1x3b z`dVL?C(30yQR`B9UGX)UUqidj+|(7(nsq-kG{hE{BsBMCpz4H$X+WptxWLGBz0Y>A zBfA9l=zP0Uic>Vk&InbUv)`KLk?0-E8xk5fw;)+FHSG-=@4>B|l{$_)6y>zP-& z1fQ<_o|HxnD6hZJQIbuy;_KmwMhEnJnr#Z66Ij1~JzqNx}5rD2=kSyaV7Z z+Q8&Y>4!=d)4f6WURoXxs-uJYhnhRDUni~LDQOXnX&p0_O-L(gRhpcP8Vc!uQs)iv zoV*ZwxdmP#m)d35OD?n!tWnbWqJB9PCt+&3E0=5!9442Q-*cF`YR{^pha8nRi>qSJ z!YY0VH0Szx>?W2&Td1`ry1;44iIjqlf0mhSaJ35P4HuNnp=!ooOt!0roIs~^fVHr* zU;cdclT{71YOiS?XY$EbzlCG7ZL^8yU0UhbVDqSKRT>ntcx3$oYi)Qi{(dN_uUoh3 zg*Tj zlAel%eJf2AC7Qmuu=9fzqIEy$<#4TXGR0z>mXz?!;^gGSw=WIwtg&e^tq(a1$@2F( zWx-#=4~|y6r7eC$Yq6@a5yL-%&Shq1lGKW;poYm>8jZeB2CTU+Jz9Bn$gU|DELKSc zmr2<%|7e)I9e)(x1*X)o01e+x!D_@+wtfnk`0cVW>U@&KG?h);EIBfqq18#|_4RQz zLU2n~G1A;UU|;|O`Jip_@V>J$t^4I-?c^+8puMb5*f90x9-_2cq~(JD{Yq<`a_7r3 z5BsWNe4C%o9H~yC^B9N6N^38iuE`Zd^&Cu|$bL{Z>cu1qdzjdt*XC7}B|SfE)EBJP z6>8WWP2^~x7&dTr`)MYy>a;Ddsi+)LppT~(BI=Inr?;v5%$8%`>DSOd?+hHK#i&x4 z>*mahx332aB!Da9-`a9{_e9cw9?-t-{qDM|a<83=vc@i|OkG_HiZv)a(d>w2Ni7LN zgpqjS;7qn>4d1GyylJJYAeO!9EwjC1VQrqt$k#m{`6@`i{d+pw7tg!r_WJ7%M}A20(NJl(s`j`nbb zA_(v2s)SUf+Q35UNuWl~vbSC#uTGarhQcu$ahpFdyAf?O=`x37%8!+#*1@>Z0fFuQ z5J#Q%iub_6tAU+v{eBIb3SyxKE9Ks#rS8nUBsFfDD_+yKufP>tXEE3OCzVV0u7#%L zim!SriVLRN>^ zVB);0hL*bL#AVVw{|IDAZkSoSF?Znhc@9lWsGYd3E@f8gjA~7p2{ew`Gwy4gLamRk z?pey?;hyO2uIj^J>fWzuC)l2sp3l+430@H=nP(55GC1s=Nqm`*cRWh{t>Mi4~Nj-l4PF%0O%@l zm$A9#I3%36^uwMwB&FVOcqzhf%O)i$QkbISjlP3k>o+#ivF{w{WyrzR>+w7yjgQ)7 zr1(T;MIMa`>yiU>l zx^639essJ$?||C!AL3FPA`FosEgf9(rNyBtzW%EqNMe=UCP^bJt5dk_`@Ml<;idZw zcx9Mr(M~VfK;yz-@beDg4Vg4kU4&z@{Sr9Shuy8wAcyyhg!(-koYG(`+tqjcyirgj z)MIz0R6xkPrFM&z-djehP;MX2XJ|K0v3-;spCz@gTm~{NYq_hH_Yvr1Z`y2V{-Gc9w3NMPba%84`?d2p&xX9N@M}X&ANpMV4p4} zRj|uK+XR74CdzCVKcnkFcPCp%5{!=gTWo^vCK$)Nq8?Jf901_eUPUcoM7Sf?HRXrt zGN#hX0>PLxdkVp}b~>#URSmxO^-K+W+aurC8VWg`nIAZ4j|P`Q;8VUaIRb4=wEVuC z@Q37~C`QO4?hjTPsjm2V@|`%(oq8An!ynjUqjXK#`|GrhkuznDMvpmo)CtbCT?^11 z_4l}d>|n_sklIOx&n?Z1pUz2FQ$N^=LQ7pcRJ$(om=ftBTds*w6Jjo(j2EKAucqwV zn|9Z9?t1f(+uTF&ywVI6Z>VTA)%$pubVJ0jgW1$pw3a_d{gq<_I{^J%kLXV1CWq@M z1|4c*61!=23gF7nYaAAQf{w2IvorApbt$_2FIj~`it>{PL9O?6!O=vhfHmLA$@BOG zC#~HNm%J~pX+)j{3ZU&xIBr#NnK~l4O*I;;)^)9z;N{=j9b*5>1@xyFNz6Zemg)<> zg)*0>29WQx9!bQsy##QuCEKrL1C#a+nXfBXH*deo0`M}wbR$7Jq8lbJ0C@N7)qUz_ zkBY#*$PAV0|Afr`Kj8QqlV;@7C?Y$4c=i__ng>(;WH%I+Mt<=bA|w7iqe}rv6yURN z#Tk9Vc=>ny|9|KMQRJUR=hStvZqm$$oiwIKqV<1!_*Q`|^f_ZLV;lcZQ06x$d!Io!!oKKP+z`VHiu!F4joz`YvocK0#`g zSx_E{{S9?S8UEW<(s$=+a+byRmaPtXU8&i{JZu6kn#RI6$1EZ%G!S32-NVDzXn&XY zpQGFA7mGhXx;@;S=}R#lgvrFkD}D~vUu>ZTHLnQyJba{Yl z8rel(FGG;XEJyulwC<^Kw`9r28TgmV2}CuV6YJlyA6CVCjAHKJXCHSx`Bcg@JT%r;Lj!uSZ;W9p{9)L~k&Jrb z&|pbBwgRt)JE!4I+17Ezd{MJQK*2WG)c*hxich^rm<<3#lC55eNt;zvm6tLxt1zj* z(c?QI`L#I&s?ACb7gJeS2|!m`jz z9Z~+jXZeV_y$sWWdB4(9n&$O^|!_&p1gPJFpWmIgL&)4@@tzO)6^aZ9G@?QM5g)sdi3 zuq#f}G*uo&NBs|euD0*?Qn}t);XJiX2M0qXQ*+1c!(~j+PG8uU{M|!#4IAOl##auv zg#r5{G)3$}wCwUFUp1QxB&+78aJHhmD6^AT7)uF3*A%%FbNz0nyW}H^dJ2RBb!%nP z{9u=8yEw1G(tXP~$A9TW|ABtq@wP^)Dq* z`Z44K&cuoS<7g5To~bTdi*q@I{)!_<~XJopAaGf+&lJvufV zP2woUNw9cWer!P>Ufv6;1MzaH#_7lMgM5;QLupJ?)SdhC8k{}5g0Xcl^IAO<|N58M z>FHw;7m~~7`KOIH<`36Q3deN z>mZA3nb2F?rzKI}Z7?GNhS)!wbo9jpFvS-8yX^=hBL?`rm&RudZ(z3$ zz20k6&FB)|vcYbc`LUvGeCAmT>dUXXR>(u~eCFYBZroYiWGK3DDst|6^zhBocp}Si zo+0?>A48unx0{2bR8NRb9Q{c=?~Am~Y?@BQ4Qy4p^mcQ0l~45Y;wnN?1DK}cUcHN- zfT-K3qoOmogBc#%-t-k=$Al_9I-k_`{HUP$Ad&!x?69D=ckd_vIz)z?9W4Z`(PD9U zlrk6-zae|EfTsuvGmccG~GK=pg7D9l7oEqa*32TSd&ky_0I-ASxo;w{fsR3+r z?(S*_M{L@dHL#ONWCagpWLk8=H->36JEdyOWMKXe(rzIqMa}z-_Y7gF5htxy00~@VNc{cJbj~A1%=S}Oc{?siamkt zI!*~n2|LwOn3lv1b)5_F*kNoD+*==2^5Y7yBmkAQT;IZ3p!DABET2?= zlHWB2u?1B=c{r8`D_Ea|Ze5i|wEpqX^L^aPx0WYXCOoY4m2BJ1Vd_u^l_q|0@YsCq zJ@D)ak;B~ixCHAI(Jybia~Q}D!1#npPmWvzgWHMwhR#&Rh9~j+V#W~7$G*`~;8GlX z}({Kl!LV3sgw!9f}&F?O`s7!{}m zMd}EEwP&K3ol2;k`Q~>4BO`p&T)!K< zsBZD>*gTcGwzes&27*pYsEm8FKC&Nx=w?37K&l5jny06$R1^J;h z^=YX83B_t-)StEg49X2gA73wt=soLlodG#?leg+W7IPQk2RJDGYQ&5}Og-k>(sGWw z{ymj%;+0XI&&3HMm+Vr$>=9!?Y`Gr_Y-#b)GX9S0KSs2 zl1@w{9}ovU-f;BzgWGHyoKtU07w``u()itYE)Xd~{N9m<&G9X12>K%6To~UHFyW8K zMWyR^Z9qpQxv#g-p+)A#(W<2>>ys4a))SIDC*%o+E;ew!FYs5MLMVD$4M$2FGH#l< z6&_@af-S^O;GIW4o;Xg5FFKr%EYP_=mdq$rkKbof#_dmo0j@ieXY);1wC`uXzc%!i zSmnt)qPag2Z6z2n0vUxf_#HV@;xy&!7pk);% zINSG?Jih;^)5&&lvD+g|j$wa0rIKSGrLFWZ9%IPzv^IKtVcRMB`l+)0;fGfEaet+( z9-^)N!OfarN%Yjbio=P3Dz)xWxn9HGE^U4E2a&z+?hiLtR=7KYs`F+S*I;$p{>=!^ z?J_A6qDUKvdLS3dqQAN$&v>+r_tj}o<))J7%VYf77BSSgqwX`j@q1m)@_~aR>S*9psm@bPm)_-K3af zcpTS|)QlV#%uBfKR5+U~)WgO^hLx67Njl5BJTin&y+Ncaq;4bd_q~RjO|8kAPl$=j zrm9kAUnQ7alZ&_zS$th+`+(@y@y<)1nl6WXt3`fW{Dh1C!O!#~{Isy&w8}>`twx)P;{ua)TsHFUs zSEs0~d|LZ~4Y%|G=5iD@XU~^lT@13TgO4cDs)5a6UXOO9Ve;pDmu=Sll=mW_P1QPG zeYKNz+cvlBC7$@kk?gC^l+PQ49Y=w#(%$f>?zu803PjV7lxaGL^E`wT3u}6c44Y5; zW@3i)q1BM@`Of{*{mw#0eUugubU^P3THgK)&Dy5Qwwk6FIiF>GIm$pXTiZ}l7 z^F?c)%N3V&KW*+g%|JePoSiH9Xi)*oVE687D}%|JS3a8t9(!9z0xD|4h|KA zTf)*J2G<*>(Q@H;=26`Nmn{*4Dmz+}?vL-Y7`;bJi1Lt!-GAx=#x@>CB#R=Ypf?LS zv$i6OgQA-8chG0s3C4RBv+M@Tk9GOx1HbeVxpgL;?_zv^J;XWMdD>By2q-b`oOs^f z2KGS)wq9BghUJ#r?Lqh-))~Se3$-0L{*oBBrc+mZ{RF9L`DM5yTq&ILp6i374T2es zz_xdBmV6TiPg}TTuH=U%Osa_m8*%0qtD~7zFebJElapB=hh;GtMijuQ9tZ(1iyQ`O zRqOYH5Uz6<)<*{3P?COWNCT2WrMt%$f|euf^4|NLre!maD2-=%+iohlxgA`&z3|u* z0)jgaMvKF<^=^-0wj%8o>+B3rASD2e4*0gOa?D*q;`N#MIXZiq_JQ(Y6;DDU2Fe>2_Jh$Z^W0SnF6QnZZQw$c zhqK|XJu|j1$;{=*jl{7FjqFG!=Ldeyu5sE$90YptcU0w7vu5!? zHFL~b1!2LOhpT6f-zxXv-rdj3Xw@poR`1Iy!y{XVG8CUD5IUX*@c?~Y6UphLkjEW| z7D=3#Zdn@Y9*mIz&!{_K<4NZqU$Maa7ZqohC;5zf*Zz#S5brht-nc#lp^17$S^^tg z_e;nZYf=Q>>$rEpT@&_6XV`AA?Hww^(2VM zxx=QXug8Yd?W7hhce7^ekI8mUO3@?&o;}p^l7VsDG-uxXhHkm?P_PBY_6`8>Ic(@R z_^N&%(N+p0D3Z)orPy8H40mc&XazT{pK!tXUA7^hB%!JIOQ#|lJ=dxG{9*msSx*sk z(#D@nFFSrPhwo}WxO%K(IddNLDL0CYW-_F{C8m$cFago(B5Ygq+d4N`}n` zFNd?u$&ix%oFN^+e}p}_22{9kZo)%e`jvbvU)<0 z`$j+sg0pLSi(^9aFsak!$psb2Xi%LA|Fb&D?K@iBMr_E>@)T?_$g&QZ#dKVqE2r32 zd&}-KTcA-iHd^4{9RFcnz_V_Df63W2aJ&%ETJo(2*Y&zAm)-3ar522GsnZo@ZK zP-ourE@+Nv-ZOG=)z{V1YRqkLy(w+=RUe;Q3}>^*whB~dceEsS%+!q(8x8P}@FOx7 zgI%6O#*|6DUYsK_jmY4P3;D+sXvYrqqt<%)A9uwGU%^Udiaq%0ctbN$a^{WJcBJpI zNWae z7pJK6@L>=V8Xr9$FK8_ZquU}%dA~c7n#gUa^FU}$Atk

)UMMvojh0Feomew)tKw zg^p!3Q^6ubDN{ga^x+}5T!}d?lY4F4c}B5jskCt+LxyJxbU)`=UxDZ{_DV4b^E#HuCSC9Tg1yHh2HC+Bz~ z%oUvV0LRG={r!F35opRjIm(gA+@nO_n2R2p4|_52Ats@|y{}U^8S(89scBxP!MACH z4+DA(MZ>pGky_xv96~ejc}-FadvMDA&po}Puzrfs;2&>HE-Bg;nvbj#^vIZPyhmmj z!4}^Cd{R)$V%646Y>#cN)4mlutD^kbNm4&kwMbR;n5X&6>%?OR>o+nAK0ew@U)!{W z!pwc5>U~@{3Y;)U!4NYaRySN2K|SZczXYrIY4p;c>jC(TmU<2eak%?#%H^x;cq0Z=#ZC7d_BsGd<37W zF^~p$dtUfIv26|G|JibF8!>|f=TjQnWvnyWl7+pSM+3Z-CeQYb6nXxmdkPH!Ax6;! zUE%%P)z3M6;94Vz&tzj9dVnGl!_d7B!?+O({s|$@F_#|L$>#K?CrC6uaEwM~xFq>sI>L7ja^J6DvDZYO@3ZOu)e*kuF(Y|#a?KjHyI21= zOCP)DQqH5}`ekVfX==3J;o%`Yf$ej?3bN$T*-db8$PEP;A9Zs_#ZD zQm@N`RFwc-LBrdaXf$y$93BVtcdcb+32^`b8U@vJi}Y;51T{SvpWcWW@^_!YU8DD| zmM?=pnae1&Hq{_ybS`{=-`Yk#sm0=Txn>i!$TL77dR=e9M15gF5%ST`%?JPmZOok2 z^j;uxJ|8Ztt7a^|ecMF$quRtms}&Fo8KwqMUP$>ROG@dmY9hy=?vrC7kbRAEs^yg{ zM#N4WE;-@g@Rco?;hFO7Mgt8&MAT;y6o4O|G0$5`BO|om>_`Phr#y4ms20!gcS>)+ zgNHEc8j2yB1&W~`C;#7qLXn%PCIIT1Z>quWjrN>@xo8eC1*3HE3*|NF-$Gh+R`D7c zB1RZ6tAAIT@z41!7%cY!jr`x*RCH?b8f_wGsyzeB6vl{%ZLG6Vj8`xItx^>adM~U% zM8`y{)H8lyBoz^nlKtUTYh$vI*;h3p)L2Od>=a2n*x)x~o{HnvN;cFr)W3ED zK_T*adB*NvPv$0zMbE3bK3J+cs?x;tcNl2-=I5UEboyLM9lxm}lKJ7jBtpo0T%n(( zi;5>e$EDLum-$LdJu>opXeA*DspsDkU9{X#^e`3%&3AYYKfm`*_7k_^xMm2_{y=O2 z&qpQfscw5Re9udz)B4W#QnhiYvj)cT-q9GF7_IjxL-jD2pGBp&POWq5}tvpqqJv?s7M34A?Q z_)-XM4{%kdHRRhAj4X6CZP3l%cKAwpZAdhttdJ>iGm@YNE3Ct3p9qrf|1W@50+M=bQCrFC~-01lkT-?3f-WCDEj&zUOnce1~+pwb%deJ8LU7@O{@?FZ*#Z@|jA!!{Bs#UeQ77JSBkoaZB|HhE!TdxaIF#k8t3tu2e0*x1rgZ1{L$ z20}$FFmDeHia6QT7dp++TbCJv(uBL%xNF+K%91h@iAouKP_8n#*_^v$@ozcgSHMaN zD_G?^wt!ttMZ*_%%#&lMX3J#ZhM@HIuS#dI6VaA|ECI>khktAkD8_uiifWu})?R;WDEZ|z5ktgw{i-cJ zUg<;oi#rRcELX=n$elt3E68@eTRJr-yl+sh@wNNem^n8zdY%ESTX8&C751FRwQkDr z7lTu$Ju_;qH*_e_>Rvp$-c;0{dGb9%AEl(4-D91WCv4IVMqJvUuu`bz`Fz|!zPi|m zUU0+xs9SVDZROtCOfBemWll+&dEp>&Rh?gMlIpv9;Ok=L*;x&8({sB|&R1B`jUtD? zjN?oyRT?|YtWyv3l)d1^#N=d3TsiaLRQRlxfwF>|x(vH!kzD26Uo9R}57&9}%M7G4 znB->=G8zMWwg>t|MWE=t-*hrFcU;gFQ9KCN!}6oiTH4^PP(iwh{pgIbBlnHEj}^9e z8=20s#zC~fV7Pjah@FA`6vK|Y`osQ4xXy1X{C3UXj=l~9c8?vWf*E% z59iP9frbeGN8hKl)P~BK5s28V>fIJ?|oyrNgzU6Qd_J0~%cnr^ zR*`gehLQq!jKBr{yTDY7N^Go7*GH-qv^`%uSQ_5XF5q_$;YGrc-5ALHmRIh(j|M9~ z#WMkAOCbclZ0r9(CaufACxlUW*R-@b9u=R(^@dHHe9Tz>CP(2p#5&uqG^YwJj%jQh z-KNo-z-alwmRogb>s>Y8fOIlCC(D|S5@=_R>;aQ)#&@Kr#i!Sah)k$hiPJXJX+pWj zz^5BhPN1};$yq&f}5PLdx^+CEZPJP4oC z)x+f(jffb^F)L-a!F3_|o)5ggp;TyjUK8cq6+8#-u^@M;Jh?r`%{G@_|E?o`z`Pwz zKmT&?`>Ua@$_}^POZ#jc8T0Qpq*<#i`qKQGTfV^(S|LB#%nI4Og3r(blgGk{{HDfr{EjNNhfW5U_bdM4ch_^^ZI9nK;O<~K}li)O5B?OJWQJg1+DaB}vRT}N0O&f>u)+zR<5VqX%Qd%7x3 zxVTvBO1SZS6U||dNO5ZMnTA={q(Wi!o;SrPhEB7O@4*_1nLIJ+2ELf$c8!V3h1Kyq z2Z$>dH~xSVN)LX6KY=f&%{8#7#^jac3Vhv+a ze|zFjk)`#VOR%Iys~BD4knsbzh|q-Bs!vnzPl;e0-A{zb(wm>`bqmvu#bJ<5EU9mI zz&k_xmqWZHQ7<}_kcA3Q=_8cbhbZ}>!^TYfFJvJ~$TY_P%lyQwQUEFFm_QH%tya6G zwLx)pM)BQS>c2UXAgPz4{$eE?tG{3Ot8pS&FCL*0{<<)Vp?7hwRYUwgbgv^UnCSf_ zNC_*}zl(2W_X7Ap*Mn*t8JGyw68IZPr}^vSSj`-*Nyy>kcTnDp$iS|>9MqD!%!fwR1f3{7yJIn4|!|qly(Fe?mmAM z$Wo`Ijr$z`^IbH0On6+J7!m%5xPbz+0QA37sbA+BhJ|M|MN)274=rtB4QJpmwLgmj z0sb3``TYfkuLSrcYgEsECm{jkgvFL)+%yV697p7a%wh0N4ClTepX+P(NMugFedZtx z_SLujq}iG;alE>j^$hTPe(ifbDX)L-X;Al` z!0lW8#Ecd`gG}M-zJS;Pll6FXg2=#coZN{n|7gwnA!S3oVG7xT#QxsYDI}l>gnP@; zsn}MyVY~`2GGAmCoNb6kE>)s1$DJsxv4tlNV^>56xq{8ll;RKps;gIXbNUhC(eN!@ zIb@_nyFv9=Cw0m{A@%=+Aqkd&@NIB7D2Ok;y>g04{*}5{efp0Qgl{Btxu%9ZlbWd7 z>vAMbv9FKJWrDM2qxyTCcBB&~3Lt<$qT{(Snuj6B#u5yFS`<=Pu<9P>Q6L@bF1qKYR(j2c)7wwZ z#>l`oYY(xw#C4_OF|=BYpeb0`?!B?}Yzzxcj+DixTiJ zNQ}~dgOZTFY3yLSK|L||%6l8{RO>o`pDla`3~8oFS!u0p`Ic%$$!X8UaX`UwAS zDD3SIbCl0PRidb4Oe_Ye-#RNqz5iL|JU?~1-C9Pb12o3{E``nwyDRgnm$Ac%ci|L; zGe@kwJ&=mj<2_1?_fYS(S0uR@5t=}`AvlHFRr*Ks{h|j+J^4Q89&Z|_-sH<+J=`3| zHSXM6$nw+eE1C5lKQMk*2TIA&c!SJV-p5Nyojn&lBk_wxDYw0h%ku6wC384OrdWG`)SDr{S&yRR91{}hR z&CNwvqxJ|97uR2K&sgjYMQeY}7Pj_XkfH+NFe>x?bWmPURI?O(Xg>$j^mZ>*`ABb! zszNsq+G<%o#r=jc#WLM1`Iup+LVN2b(Q)TAdO{YG#*E25=(DUt&l|Sq~LpptcK?;Kh;@FM^Tm zdo*{WZqZH0ZX{6dU8Il_@9YA-a_yVrfc2hg;T1gPHA`o`Jja!6nbsK=>@KMq+QP^2 ztTf|npXon1t(bww7(`r1S5iLL^RF^6IV$R>b~?|dp@2!6=JYv8Gs}F~z=hsBN3Wsc zZ1X~Hj5`(jya~3gt-0B;ZERQ$OG)*G?nd6d(enG&grXIZ`k(a1*rp1D|J3lbQW5cZ zE1f6F!XMRyY$J|cA!hLDUzdsb&6RdLMJ+a63R6EaxUAAhc3AW?zUc8w4OT|56-Nr2 z8CKPmfz6+UnuennZtGDp_SAltN2l?OF8F1q9oOOW_i{LMQ_;=knL&>43$B;(%yJ0t zI~t+(^*haWahr#z_s{kX8elYfLC@;)iquSUkHd`Jnjd8^e`YzS3A$yKeb-dH9DyxC zJD9NdM13RMSIuKYh@S>aQpH=X^vG-=;#Ivk&C0?nyCTnD)xEgUs|FT3MVLd2uUC^- z6j>`y^bsbjQr_7`J`u-holk~me!jd)&mL}@JwCNQ$Sxjqc~)snJ*(ve8r_3`aPlOj$pQgA zjIX2h8kaMC$2>rkZI;eY{XTZ9InLjVisVawiJpFbSPV(O7NmJc9X8qa=-Tac-PB%l z|C<%6r2TlR*e#S)I+4oOC9- zU$y4-G&b5gwuqPrL42L5cRBB70>i!Wix_9-DF4|p5^Xu&(|?c%rpwT=FyyWKg<|Gd zr`*1<8|}fkv;FF{Nz8D4yQYj#)X|RixYSMaX%{G5lb&|6sNVX1;W;#i`u>w0P(Pxr zxCHWzV(IZs4x`wK)|Vo&~C({TSQkTQ1yi~{s?uFP}|KA^mSc+ugZ zgc6KrvcK>_m0=s}`3eyMdN0l5wZcCEmv1&F`T2Gu?;UP#mW|zS)>#g>z(A@ z&t6eK=3JuiZH=r?2F_$?;@^$n9A*mchN~+cJEy>xl5rQt^imFF5RqY9aMnhfNBuiX zR>;-h)(JKg??^tDfYUAm@TmWUO;nXNq_dBekHb|I!-OES4@9UG#6z4?M)e1-Y zwKi@KM~@9T+dwbv9ss_gCkC-I#8pyn>^_V+Jl@mOUDa$X9G`&VURpx7*M*A#KVk<8 z*1Lo*wYA*^Vjy!%whh|COkqUfy$ILkb20P7F|AeGn&`>2EDjqmW$481=WRvVdXaIH zA=9gzCDjR78}Wz=odUHBj0@&!v&xz<%s(j1wEosPHtIlGY4zz>MH4g_{&82RwQU}@ z*B@}G6RFS z@j)R8?vU(bZ}zn#!^gyIv!2;dj*i?8rT6WOpBGmaM^-X9xJ|2bh0_v&PFJN2(|sd|qR7Geb9NkWtJ0vq&ZPrE{pK__V<-~g$u(7FmtX~nUVIyz2|Nd{rH zYiae&MnyfK3B_%|b<#&p=dR~c4+l;^w>xMW?n_=}WS$W0?lCWLy^?uJw^7q|?P2)r zkyVC5ZDWheWmxM-v50ZySbWdAHqOUx>67mf7559z4%z#s3G7oX;a?~SuNNMlxRSK& zzrKS%LTn~(Rj;d({h)o_h;uhP0jo62=5(gF=}AL%AU(_M7esXBGe-fOMB_ScH;%MacepH~DHA{9+Y@+T5nx;)6B z_y}d{d|>8YN3ffEHxfiN|D``~aHNabZ>L|0e7+6I$0ctPF3C>eXH%?}mzx+Gv9*Z) zS=!{aE50wzhKgHlN*AgQ-UTjhV*1}PmD3-OopE@7e%3$6J=3o`%aU5;3}K8bk?S~P zFHh6mJUiWJoK7F!Mz{-k_O2}>0+_D0m;QNZ!utSrCEKG~;+4pi4=kIRXPmzbR4ry# zrhDFp%Ywv$^qHGTB}9Xj$;W|m%3tqp51x0K_ac$D_^^N@%v zGD1Q047_nJ*8QI2kM%ize^T?9{fz?R7gr;ZcE8EkyrR74wCvgYH)zp$K^y!t@uh8j zVgRkb_R`{*jHVeXbv7-|dp0i}5!MtrOK0`Stmcko8vtWK%xXI;s%S6;RMqhNC-vSmmWG9P=b1kr&7ak z0o>_rQ1i^2@GF9o09+%=)-U01FBHvLL82dH1=L9ifJCz=ewmXACR*`2NRskMLx4Gg zx1c<)+mIFaCB9-?M0ngdy1c}b2cd174$?=|Ku#_RTFg^8x>tvO#m|FUVhm)&C)h#a z?5^m61KS*Aj}l-ZKj3|UHZdr=)w11rConSA^31liNI1OC{YVy21AR_WY=S zk6u8DIxLtj=Yh=uwSBJSfuH!^M|bxCX$>lml%Z0vgPejzw=}?!fyn>b7*^P2NlAfj zUfQFQV2(OEMMcubNrF3i%zT_4#iCR;Jk{2U%pz_-M6h2lt=hS2k&)3F+VYH6u=!Qo zRH8h5i7u<0TR2Htg#m(x7Qp`_vNZvmPLc~8+jB18Q^dxk$IspUoq!JVx0ZOX?l1l0$ zUSQzB%9@ZY6zSn)GqEruHvMBFK^_vX3C2D8h=$%CT}B1{mNGgNS`vMLGq&2-Z4DAz zjAMC3KjMUlSJr>+z^KJ$it3}zjq2Kb)Vc2gKt_1PNjt+0sD&Q;xq-d`@4RlG7b$gI zZ-8viW&m=oRt^Mks5 zt#Ev2w=`K^0uh@%aoJ!sGM(z;=+bYC$8|0(zHMj(o(syes8A1r;oqG5qT2IRB7-N1zl$_zZ;3i!$_OsR7yx4OF8b4#iN z@UxGs>(i_Ary!~h3pP_uONK&IBuXUKgI-(PYDF+80G&L65Rbw|YzPGjQMj<`xci7& zg1l_<=MWtpf)D8Zy>1P$yqpCo0*aXcf;_YWxP^Z8n(2&Q0s}$KukgXycq{dHwz#=0 z#8kl{mFmSvAjFWkue8yx5wV*R;iOn#xu4 z=zB2Buw!E`j~G%A?CX?NwzGZ#A{$X8>MZ4OIQm}+pDXQ;`RAQKE;zdl<3r}mGQQt*ijHMNqcyLS!Dj9p=R z*>+9yzNH)ok<&+KwOg<);*MCAFOEK~|Ai~JUxb?TRCa^@Cmpt$$>akM36N;wN5;qCj48{sXMX2T}eP*4DFuCdQD&clPq3$V<=iUHEp z)ZdhFDS@ZxCY*A6w4A8BOIB3q@`$@zaV{E%%8B5U^|d(ODovtn{P-y~m9X4vbYM{$+=TMDUM~=^_o&9k0|6m`((yce z1sc7F-&Uw7m@!NnnW``c|s`2F1ex|1_QC5IN_2D_EmE$S;jYfbk9T zHj%CgC!e!pQHjb%r8BY^;VqR`02J?ez%upwL`LTLzLEHf=cDig<-M{5TOt(X4x~dG zl@1)}4R6B1sqI=y)%PcP#s!6V^$uK(hviB45&&1^M?A*iLl3_ytv%C7)3U6E@AVsU zIXKtVnJdqh8dZq55rIFkCAO!pBP65;x1NfWx!(HwbqS4EKhUWgIY^ZFhlV;NJ!<|O z(XMX^g9MUdhR>$#e-zQil>Q#`N&+D<*t)m?1Ujg#EtJmFShc9n@THvsxZ9%K!A?9t z>~n3BY$63Fk*y?pU}tc6#5&pyT#3Q|1cDn5kZS@}bC+`~umgejAW>u%H{v7hJI$=X zCwC(=$|X$5T`|ZkN^N;bGr+ zq>~2#puNx4%L91So_>UQpUE`XRo*(SMtv9d8WLh_p7l!EfTbM>kZFzDCwO{Uo|;Ek zmpkikKFEFLf(_VS03@^g(s%T>b>Fj^aRz9#@JzyBF<;EID(A<~X?RIiry7OT%U-i(G)iPu_Sp^#?97B%b>P|VN=6iKvdmVF$Dlf1&IYLR&8U0q??UV$qEDpBxc2d!zo;_Qm49;?pp zgudHb;7Xxo5^ptjmI+nbP@UN4vQ_*&&A{N}fHkVnDYy0v>x{-;cAa$#o}12Wvnact z??^|)j|9jhI4!_-S~6xQev=}nUTQ<8stQflU5Q=fBMxbK zm4_M2yJgp2B?|r}U%04jJ_5%TM(@UvD7w_gt}KmKd)C*Ui_glJ(n^va)M3|gbh`Yt z5q$G)U(6bQHMq50g3^}peH_jhMhzNKtWy~O<5dLFkngeM;iHp2Dbb8dBD7iw3YE)y zR&Wp;oPAin#lxF`cj*5Wh>o7Yl(jjg)G9}|LfwO(u2bN{2YZnJ^o>Rfjgx$~L?fbn z1{vPlul_(6RrjSHYf)IE-{Ix`k3Bc{pZRC6`ZPalxAa^V)eij;9aEk&myb5dKN^ij z!1E9W*KLYt+^t3^%H1uT2;$+Pr7Ps7ZI!tKa|NqvP1a6%)Y@vaJ zfWrK+l6k>@l$MrqRdtJd3~2vtkCjOY{%+1@&r5)L8%x6JUEqtGnIx60eLgFyQNaGW zX}4)W&CsIE*T_oKdVHh0dA_wME>}W!_SeFiZo>DG@7~_V`=i3%ldACZR*)bOMf`WoE{&Q1dyo39h^d9~7Fy7WxJm2F3Fw^ZiLdMMW) zqxjb5Teq@^XHN#vS3AdS!@91iML6Cf0&1!COG*-h5TcuK<7HIFuKtQ+bUv|ykJtoA zf+N1w2;pOBvj)jLUB!}b&80W0AOz=0sgSqEb_Ou>E)5HcDoE9CayH?M$Pl6K-otZgr%***ClEWXG zlq}3)UV4%(a9!G-byT|UOtF)$MoVrttSUUQU{R6CzauAKfC32H5`(lcQrO(*fD0``R~%?#)AtsY0IfgFk{Y}YCVf~d-SUnjgBnA{IdruluA zmt~Y_$UV|JPpx!anFE_HKlgd!J>xID?6!LdKVuIO%xrGv<@o}VH%)rh( zw&WkCz9NI`SEWK7)AO872AIt27=LvbXZf^XLMyMpam809XG0OEM&K0p?XIziQ(-qX zS>qhEbJhnHUVh=|&AkildivJuLRyc$y{_gZg8JYCM36!?(QZtp2~0B-$JtT+b!5k920cd7v9i6S}eMyWq~(dk|(eb zNSiokvRotHc6GRSQe+554Zto%6&Y~_dA=OAtzD>pnafK4lcJ7xM6yRo0y6?fA>sIc zlJw?7T|NpB)k~DZ?%BN7@_hybUc&5LH!~AA4K9C95HBst$ff-4l+<+ywGN9pFfRjIQ4_|js?G!M-*X!zqL!t8rL1cJl5hRDdsnI zE|#LjL8EaII8cD1P#&`mZ_Lvc8U}gvEZfj^%XlAWDwn{B%Z^Ppl;cae{3}ud(2Kf@ zr_8q_;%$%+D}3a~N|$gi$RA?QDu;Jt6yrm!*dU)#kyUmQFvR3MJrFBPaJ-r@@($&K zI_jh7b*^bR?R#1cs;lMO7$ig%A@cI|(PeP{_Ptlm=^-ImnuN}TBT_%GAoa==!(*P@ zW;Gvpw0vwUkaqKUF7TLE`B4mM^LZAuPk+-ls-Dsc}{N z$T!aI-#;se6K5lQSYRltQSv&i^lSokzr$(@3`H?A#tXztX z25MN!IJs-olXg489V)1Je)lBm7v1x3VFePk)5yIxEF~2>Mi}J+lFMoB^9{u%BNBgc)gZudchC{2P zQ_<-aCZd54S$hunZe-4*MT!;$FH~3A6i~}r2L9lR0f~qJ7(g2FI|R_Z7=-@Rjrb{F ziApPZOuP2%=p-Gkc+iJsKig)?0oI;hBx6I~^CtDSbL=a|zFx9r<9d-8OKeH-K^x8m zm=gP-9gPms)}Q!|zH@nwEeyK*mSSEI&(S5KwBdx~Bq0*$=0|%j_f`$ESY5Xn9YR3i z;GZ#yxu0azSpm?KQ--gK!;!;^vVTJtVbgd?}h6q%$-Dc}tGl=PIk{ILCo}C^EIa@tsYGZ06NIiM5;vFD-TQ6=5X!fbtzj zbNJ`0+4n*9f8aJ+s*>)B;{EGk9l`#V&IewX-S1*dbE8zPxAu0F3?jos)^b3WTtp;R z6M&Z4l%EsM5kIQi#`_&~2iRZ$s$(FtCZ{UA@BQL2|MaB}T}+f9y&}n0Ljx9OpqMbO z$u{9uTFK*bL-iNj+bA{rbE;-xv}VB)GeKNJyiZ&Y$IzuXepj`$$ZCm-23U>}(+*n1 zvaEallkU&a2tWeSNG_E)E-a9d5l_M-5J9V57F?qTaEu-Wdz1;mSqa$R@*JliPgb+Q z$=S)QEMX5{O6oU!0y6z4`H8s@$S!z?rLpmcH-n@;iD3>Tji~4)Vp176Qe9(zgaQy; zgP2V;jm8ZVW=QRtqgB8S8o&hdNS9&^$9_&-KVQKFV_HG7B%FyJp*Qu6p5Qj~*UL0G2-KNDPIs7fQ-f1lWTYwbw z{i7vt+mwlw5woZM7H2ZNR^U5)PsVyLTUZlQ@pT9`nD&N^83y2rWR^^DNqhpTsQxaT zsNn*!@kMR-?T~g|Vr{n`N|JV9%?6M+NfX7~#c9(KXOb|j+H|ShjrU)RjnWjvye~MV zz_iq#c)}(O86TW_umXGIqzsppW`;=Wtd_3KrgH> z4lS}KVdR(o87@`HqSOSg+hJ!S8CH5H87@`z8eQX|JKW*Rf2_(c))JnP+2pQXZ;Jie{0e&QEwjR@~*swpy(ahF%h za5uOQtdnZntuj4*!7mizoU~GzWFK$UuUM^U)%x*j>}U?KkU>o7%bg->H1W2N)H36@ z;g#jT5Xk!YlFCSizsG3;6GcsE*LVK~(F-i$1VulI3{C;IGUMkMLq+jimVzGX8+#r~ zEsp1|z+^-iGGwnzK%b!IzGgC-2)7b5c24<-O-CTJU;8 zJDlc?CiY*K(9zm=6TD)0&{QOqn>a3OYJ3YQVfJ$}C`)9jV##f1gXZK~EU|roj-`R^ zN-{ISJf1#jVQOB{mP(#aArdQmQCb#dFLMb z8cnr}?4cvw3Xm=Z2hPl;5VvMb*hvP}8H@H}O-UZqd{F3vnYS9>EQ?qnR$G0JZ4%6S zpFM>w-#W7rcOt*@HOT=;J_k8wuvTTxiclzFYCh(r1;ViQna%a|9a-)LG!E3g1k6 z&4p-2f3&Vl`mSL#YsId&^)>;rA;6=N&tOc@T^z)$H%>5R1W#z7&fuJYG>E}MRX9%s z#ktQgse%jF+QLiDVpYCQTgaAvugI7?qp#9<&SAN$R42(swrsI#EEp>~3%d>rHqn?N zQy{!fyylwW@(8r-gp$@S>25$%Y z9BtjqUw)>%BRdAm)`1Yx>qu7wey5Af`R1N2nA5u?eRnl>ekRHMGq)Qrz&o1&tkxU8 z1mK{ooMlO9+S1wbv{X$E2 zeDI#Ve8JSz*p_g+ke1^Sv8zMA-@KCiSBF-(Ly{+il%?PY`}Jp2cLXT)q}ap``UP=O zSyOzaE~Z|KY*JEoiwaAcD5$N@#Oe>n=2IskIX(?D08I9|yQxKPyzAhdk!#>+8VjFf zI6Z4o%5RxXVZ95?SKA zyQ`V}t`U+pv|m>a@P|1@y*;B^mO3>j%2HG0=8`L+X|2Gz}d6U+s8N|23({`(A6OdxpC zg3dfr@XxN}2U2t8rRH(-QjQu7hW;1!Zo;vJY4wNFOQLB2&o3#j5gR45!*X`V=eOTR zJ6i)?G}3Mx_Bwx$eoUOxd(^+CCT}d>FcMlHmqcgo54FCYFBh~+}kk1;Yof~^$LCPt|(?i$FzO3g5F+LQf=+4 zv6jR4Dr=Qmaug?)*@^EbFxI@T31)Wr5{cKxkn%sfW*Km}D`?~{hvIK)^0!UcSK*P; z6Jm}oC}iY}eJJ9#lrDf|3*=0zq-6_vX8AUZfBLYh6j@#vxtDpndP;oxYehaBnd4nc z-IZ`~x=me@VbH_XoAk>1K z6}+lrnUI~~V7cCZ+lzTHE-r;7WIq3ROLiHB{yrvXK0K@re0d)6m+lj2LwSf+=$ytALm4<$*I~kS_EBM=F=@=rEl3 z;@+WM4>-r z(_obFAJt_|+VT#gdtyIlGLaH0LE}s52g0DqzBl>Cc=?D3)1_Bw(tYUIVE%imjt|LbIS%U@h)jxEO~OO(8v=8 z@PG=jPZ_QxRzGJo-XZxt{fANxl|g+rw>1CZI>olo@}-o_PS@+gfW^c%?tA-0hIXw` z^5TF_EeCR>+nrUg?3BDj6Xh-D!+%odCdo;hMf-AcuPV*4CIZH8OoE^5%GZH!5wAh-D z2|Xok*Q+ktc^ZnXMFayl3ShhP4AEiak?QsNOGuOOhOP^PH-$q2ibEtpp2pxmIz2fP zX5R6P=pji4iWv3uvV((B)1lBVlg}@4enjEyPB>5rKBM^OZ6i@C;cv&eM=}+}VeumW z^|6`VniJnRwAm=yOzpC6-7GO z+BAcgzuQr@o^z4S7#cF|s`+Qvf@<4Tdr96&Q*X}J5|;i}8Dbi}8(yj7oUBGxQB!h{ zXKY@QZ+tRismdX1f1WxfI0Iq3RoxtWbIQ9hvj-uqyAJU5BY)6Up1Utz4*~IMmQU~M z7@QFowU1|eY-f-_^PU~$l3Jul$iwtJur@``uAkQUmlkuMgqY%8Cti{1nq%&(>r`{R z#J8Pa{tzmrhz)bJD*&-Wp?gBG&iBtdvUO)jU8uF*CRXmDO@MoX<=HY2A{~^M_?1?( zj=$nM)x-#=YpntA@A3C&3qa3o+}HXw+mVhIEDTJ_H7c_E2$;3he+#7s$7zsJ9XB)? z8Cx_vdMy1}Z5E}n)y#t~PBft!PbYojSTArocwGYvd12wMoJ(Ibry6U2-WIO(MKMLz zIm7u{m6M%QE(5kYg!p?q#Ei%B8iM&8}WrSdec57Kk{!&kUI_pc; zyO&lreg$1tYSzur+oF4y&QJD^|4&SpnrB1W_SqfnvX`Bi_vfCE2#r&cg*SmL)3xDD z4p~gDK`xp1LZ4g&0LT}Kdujk9oY22LbBY87iH7;8uuOfXsK2-G+fIDhG(+|0&s-M< zCW-}gjz){s`0k2Kuo#8xv@}m+h=cC#=JA&ziuA2SdhhMxc0#$T>n8Ej1%9i2-p_N9 zcCD75IG33c-+tjfRgC-vp8CBP$Zpvx_tn8$$=#fTXj+g>sTUp3D8D{dl753x34NB_ z=|jZn(wg>MG$l>Agpq2%%xaEGGe?e-fFW20`rtrNiKBPyW#!PxJe@l z5`zw)RxADE;$Qel=wp0~>6asmj#!M;A__AEW3x$(*s<|vsc~I-s6}XX_*Jdt^f|5am5sf&yI&96y3>~frKm=!0l+=Bc*HRzuDwGKl^-!5JMa!EuUwF2o|@)jt2!7m5~lUsKlc6h%nbq?iK; zYc#jToJFhJZ9E=smru~(x4$fZxv%cN>+*_WxWUPXL4{6iTdTviWy-UZKf|>>l_K-Y z{#kIN@4U4RyFISnyCahW1dzx`kZe#eID*PcBuC;_Vr1n%y*q=-%D<5xAg|XQHbdD1 za8OsKP(^|g|J%zmI=PGn``Buaa+|ah=rGNH!A?D?m>AYCObf<(- zz#;(B6^Z(U6BGTSOW47zF*4mhF+)eHTic3F+3~@Ca1yX%Y$8%GiuRe)`b_As_x|~0*5BDQ zFYs1^D8z0hg8n%agiZ8I&9Zj?@vm2hCn-$;{#2k{&Oa9tZTdH9*vBXH8mwJmNwy3>g^cix$igwqqXY zTxD7UG^9Jn88;AXQYc@V!d#YBvpX>RFA&Hh+z(>KeOW>5{s8))4dOGrUvl?Xinzf6wAc=&jz(yuA}`-~;Afh; zDaa{UHqTkG8Q6$}guhiJp7L$L3H=vE?sZA5C#b}pw7D$XNZIXl1qHF3k4DqfCzo6D|j@au#NUO==%K|ke_hEUi)BD0UnfW$1WujmJ+H2B7cF=%wk%~ z73=7fgFt?h0bLhBbcTfa&*~L`4q)Tb%CRrLya)OA=)aSckU<6cSigF$Z(xukEiwu` z?*X{LA^$0N!hI%pN(DlC=Bnw?A!vtdQf-DW5=fwvD*pN}8tVqKNL$6%K@XuHAR;z#RCgS@{WZX1`2!y?d z_F8~yDFD#*81QDl43`>ScIT%1F*)&VvTlq5ykAcw+Qg%jfv@U`(EG5Ibc(I$%#bV#VlO#khe7I;Pw)t)`i z6vKTW>Q=-RmV1ZRZJPJvg25}f3@()@;c^|R;PKL$T4(jLUxUt4sg&Cq8~omN4w}H` zu^QG%)mMl|LyhQO?poDX3=_d}cnwGZ5BcYRa(a1i9pOfZ85JBC&>v|+^%Wy+aSaW3 zkhJCfFZUcfq+Qy&men~`Q9*6>GA+*fhK5L}HnYRben-2a4=0ncEPGL5AC#a^oBDM8 z>6pzqjmW5!AWahYr{P<8WyK_W??|2~(=$+L4r=hxCnZ!=53+iY-p}U~R0g7p@S6g( zF;pZfua2qU^Ei@1)F$7tDm;uCKvHDm)ggcgVpUi&k zFheXLNvsFKc|{rZCTK&>LZ0@*L|7H0QC(3S$-=zwHwZ8JHIHl-Rr6oFkZU)AhvS8! z&Sw(n8mZBMrRgG`k|p!FDl&HwQZ#EhYjUlfMn(J3ju z{VKS=<5f#PU6V6QRG-AW6Z$h<(s^X*;=!%IgqHm|tjd&6`N_3NMs?bJdw;gH?T8!IENvw9OjPu1aDqwG~(V1KQgjauHY?i#D z8v}!vWM~qTcvpC`OJ9NBul;FYLAj{Y2+#!Fd&Kz+xQGaZg@entW&Afr8OHn8wU^8C zb`I~1YODSGil1&LL`?LsTqK|m-8Wy+#h{ode~E5oYv9m(lLS9ypg8b8MX6YqTK9Xq z2X|HU5VPAp^r{sK$e)x1l+*D(tqNO%rMqq?o-4^h1;8-W{a~dxmuvUho~}Mp?1J8X z=&-&7EUj8Wr|rsT2c%ck8&@#v!6p{p>AF|)qqeIjM)_$v5lt6&MRHd8me&c-TFWOZ z#Elr!q^B;7=ZM`~)>}`$U0!E!EmT-uM1MbN@i>_%FjiJ9$crJaWkdZOldZfNnf~NS zjCnIX+WCqhvPK~%%(v$}99UI4@gnJQ7EPVH{tT#ovDyp@jpw)eRXS6(n#N-7FrDA) zZK1&uYzmy%77Jxy#jaL~ZA=5B_zAr?1iqG|D17bc6upyMMgMhtv2~b( zi?1@LByTGBwQEyJSCSK!Fodsi>Jn^KuO>QPDBLVZQhT-i^X`a*AN{noM2OKyuENYEl=kJ@9q)g>GdL}kdBmW z+_i85GdONB)JffAY7I;Zc~&!7Ca3FA8>}L0iRy4_pC{Q}{(58Ab348#pH`6ip17sl zvICeCk-23c9UK6YRy) zaMo{G75ITvf`j9vT=gL~>%wx+)G8w6b4$xJb`Inf+b0<;e>sRw;XSIi>uYqpvhxZG zC=e5NeMoNER^3?*)&wmzhPu!1yB_TzIC)jix5hava%}1umsf8J5=w`4y{oWBmS4vD zYq=i`Zzor(Zlj0G=bZ4@6_O8R4Ve=Wt@MLpm7#DCF zzJ~}(F4|9hf!$a~#z=Yy+Hr;Y)KrcBvR(~pf^_-h&$*Qja~dCycn>>p@l}0UvYO<2 zh@)N-{4Zv01wc!eqKfp;ycU|8hEP|+#>Dr$*(}q~7W~_&Vo~u3UEQN80wWJ@VkqhC z$`4}IYvP5yt*X_Be*9q9SH{DVlao8TVIk99+q&+qVF|R~I@MQ?UrTOBl9};iG_lIE^nnhmQ5Hkk&G6M>j=E0IsW?k$RNG+ulg26*$wD;p%- zG6LOqW=so1fA;CXrNJ-xA0nMlWFWG*H`*z8a!BMaUGAR)Uh|@sQlNgAKcGX5$JqOS z*C7^&;UsdR20kb~Lt)iqh8nUz+^el2!UmPHaa1H~L=eIUO8lKhHY*_L7SREYCL=S$ z!>YtN&;NtS3J7c7=3y03BtA8ITUeG?^8}@5MV`RhsbL z`oBw&d-$HKBdr8hPmUI^b?{49O>x6mWjaC>BtQWX+y`hOoShv^lfMVH zCg&|IK;sS|ZE~;k9)AB{wZA?5=98XS=QOYrNFTl2uUYYJyV!R>waVeMo>6OINMOc^ zO_;-ScS*}f5h_DfmR zJ0vdhcuQApXANS>7ClziRse`wB5S|O$5=M${lR6j+ARtHgw@5ne_=KopPWnwVfc2g zyZcWDnUdoGdNs~GITz>fE}q+oqW;6)_W{B4UjU%!{8=#>s#)F)57$=bQ^(DirYse;C)%duBuCCnPjqsVW_y)5z@EzVmw zYX6i*F4mM$Z|?h&<-O5o32mu55S0iw$n#7%oDZ2WKTB$ODDU0rgILW<2RT^D<`E(UJF(|eX}+XIiqyHo(Agd##T{-3n)kw z$o5ZPD{@8F#sd|)i_eOIirq9bo$tzTG%8F>4Qj7=M9-M;@1{?}xx(4LppzFiYbkvaFa%kWI>%oa|^*eDzfcmv38uAioI zZqFdkszY?cVTAds4aP4b)*JYr#uSP&2+FUWPkT*rE+~0kSb6Te*>rJI>ebqx>w9T< z(6FqTS>@`V&E{tQ!Jp9!@fzj}L3sACQXqcEa?4Ee3%EMhf{2<*4HejyTy<*5m2`K+ z_8cqC!8_N9x&i3`ZvM-^qMe`th-dNMy#3oma(Pi=1)Yz*vjs(pQv<3mto5zAV^0eq zc{o-2-2}$BVo+%>K$z)5<5gn7J18CSByDr5?#uN68rZT#KLL8?l)&(JxsryEIVpD;;v9#mNW{!*eqJJGbE-6I~Y+!9c($qQP z`nIYC@&7daeoG^;f$ohT4zx(8r7Hk-XdfI|xyJLNxiCYdhj!`dB+G1p^l=|zmUs4d zLLtW45(^T*P=A1tmp1a}HkQ*d4+0*Vmv{Y+;H7y!LKib?!L`8us=26W+=LVGHw9UX zOHmi(dWN;cU|egur~*UvX%G9u78t|1!qUV;IxL76-3Wm&8gbl~DNI>%B>^Fa55Itr zFe9TTBj&2DD_|Qj$#li^D=w7$q5pYN1E8pnU2JXz#)m2`{$3e$*l`au6}Z1&Fn_8Byomhp;uDa1>N;C2dPmvAb&&p` LAYLk}>-RqZI!@Sf literal 0 HcmV?d00001 diff --git a/Documentation/Plans/game-diagnostics-overlay.md b/Documentation/Plans/game-diagnostics-overlay.md new file mode 100644 index 000000000..7eb7abcec --- /dev/null +++ b/Documentation/Plans/game-diagnostics-overlay.md @@ -0,0 +1,22 @@ +# Game diagnostics overlay implementation plan + +[Approved design](../Designs/game-diagnostics-overlay.md) + +1. Exercise the project model and SwiftPM command builder with a temporary + project containing two targets. Add backward-compatible decoding and target + draft behavior, then scope the Swift launch environment to local product Run. +2. Implement an isolated frame cadence sampler and shared AdaUI readout. Mount + it above standard Swift app content and the AdaScript product Run content. + Check sampling, input routing, session lifetime, and pause behavior. +3. Wire the localized target setting to the existing draft and launch + destination. Exercise the mounted settings control in both languages. +4. Add the AdaEngine DocC article and topic entry. Update Studio's Build & Export + guide and regenerate its offline copy. Verify the two-target Studio walkthrough + and the Swift environment example. +5. Run focused Swift Testing suites in both packages and the complete contribution + profile. Capture runtime evidence for local AdaScript and Swift Run on Windows + WebGPU, macOS Metal, and Linux Vulkan. Include UI under the readout, input + pass-through, resize, hot reload, and Run/Stop. Use the native package tests + and platform runners where available, and report unavailable checks. +6. Submit the final scoped changes and validation logs for independent review. + Resolve findings, rerun affected checks, and verify the Done gate. diff --git a/Editor/Tests/AdaEditorTests/EditorGameDiagnosticsTests.swift b/Editor/Tests/AdaEditorTests/EditorGameDiagnosticsTests.swift new file mode 100644 index 000000000..f7cad8b92 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorGameDiagnosticsTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing + +@testable import AdaEditor + +@Suite("Game diagnostics launch settings") +@MainActor +struct EditorGameDiagnosticsTests { + @Test func draftAndSavedTargetsKeepIndependentValues() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("GameDiagnostics-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let legacy = ProjectSystem.defaultProject(projectName: "Game", buildSystem: .adaScript) + try ProjectSystem.saveProject(legacy, at: root) + let original = try ProjectSystem.loadProject(at: root) + #expect(!original.run.diagnosticsOverlay) + + let model = EditorBuildTargetsModel() + model.begin(original, product: nil) + let mainID = try #require(model.editedTarget?.id) + model.edit { $0.run.diagnosticsOverlay = true } + #expect(try ProjectSystem.loadProject(at: root) == original) + model.add(copy: true) + let duplicateID = try #require(model.editedTarget?.id) + #expect(model.editedTarget?.run.diagnosticsOverlay == true) + model.add() + let freshID = try #require(model.editedTarget?.id) + #expect(model.editedTarget?.run.diagnosticsOverlay == false) + model.select(mainID) + var saved = try #require(model.draft) + try ProjectSystem.saveProject(saved, at: root) + saved = try ProjectSystem.loadProject(at: root) + #expect(saved.run.diagnosticsOverlay) + try saved.activateBuildTarget(id: freshID) + #expect(!saved.run.diagnosticsOverlay) + try saved.activateBuildTarget(id: duplicateID) + #expect(saved.run.diagnosticsOverlay) + } + + @Test func swiftLaunchOverridesTheProjectEnvironmentForThisRun() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("GameDiagnostics-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + var project = ProjectSystem.defaultProject(projectName: "Game", buildSystem: .swiftpm) + project.run.environment = ["ADAENGINE_DIAGNOSTICS_OVERLAY": "0", "GAME_MODE": "test"] + try ProjectSystem.saveProject(project, at: root) + let service = SwiftPMWorkspaceService() + let toolchain = SwiftToolchain(swiftExecutablePath: "swift", sourceKitLSPExecutablePath: nil) + let command = service.makeCommand( + .run(target: "Game", arguments: ["--level", "test"], diagnosticsOverlay: true), + projectURL: root, + toolchain: toolchain + ) + #expect(command.environment["ADAENGINE_DIAGNOSTICS_OVERLAY"] == "1") + #expect(command.environment["GAME_MODE"] == "test") + #expect(command.arguments.suffix(3) == ["--", "--level", "test"]) + let build = service.makeCommand(.build(target: "Game"), projectURL: root, toolchain: toolchain) + #expect(build.environment["ADAENGINE_DIAGNOSTICS_OVERLAY"] == nil) + } +} diff --git a/Sources/AdaEngine/Debug/GameDiagnosticsOverlay.swift b/Sources/AdaEngine/Debug/GameDiagnosticsOverlay.swift new file mode 100644 index 000000000..4b381d2dd --- /dev/null +++ b/Sources/AdaEngine/Debug/GameDiagnosticsOverlay.swift @@ -0,0 +1,46 @@ +import AdaText +import AdaUI +import AdaUtils +import Foundation + +/// Read-only game update statistics for a diagnostics session. +@_spi(AdaEngine) +public struct GameDiagnosticsOverlay: View { + private let session: GameDiagnosticsSession + private let frameTimeLabel: String + private let millisecondsUnit: String + + public init( + session: GameDiagnosticsSession, + frameTimeLabel: String = "Frame time", + millisecondsUnit: String = "ms" + ) { + self.session = session + self.frameTimeLabel = frameTimeLabel + self.millisecondsUnit = millisecondsUnit + } + + public var body: some View { + let fps = session.sample.map { String(format: "%.1f", $0.framesPerSecond) } ?? "—" + let frameTime = session.sample.map { String(format: "%.1f %@", $0.millisecondsPerFrame, millisecondsUnit) } ?? "—" + VStack(spacing: 3) { + row("FPS", value: fps) + row(frameTimeLabel, value: frameTime) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(width: 164) + .foregroundColor(.white) + .background(RoundedRectangleShape(cornerRadius: 6).fill(Color.black.opacity(0.86))) + .padding(12) + .allowsHitTesting(false) + } + + private func row(_ title: String, value: String) -> some View { + HStack(spacing: 8) { + Text(title).font(.system(size: 12)) + Spacer(minLength: 0) + Text(value).font(.system(size: 12, weight: .semibold)) + } + } +} diff --git a/Sources/AdaEngine/Debug/GameDiagnosticsPlugin.swift b/Sources/AdaEngine/Debug/GameDiagnosticsPlugin.swift new file mode 100644 index 000000000..68055f58a --- /dev/null +++ b/Sources/AdaEngine/Debug/GameDiagnosticsPlugin.swift @@ -0,0 +1,28 @@ +import AdaApp +import AdaUI + +struct GameDiagnosticsPlugin: Plugin { + func setup(in app: borrowing AppWorlds) { + UIPlugin.installWindowOverlayPass(in: app) + } + + func finish(for app: borrowing AppWorlds) { + guard let window = app.getResource(PrimaryWindow.self)?.window else { return } + let session = GameDiagnosticsSession() + session.attach(to: app) + + let titleBar = window.configuration.titleBar + let titleInset = titleBar.background == .transparent && !titleBar.reservesSafeArea + ? titleBar.dragRegionHeight ?? 0 : 0 + let content = UIContainerView( + rootView: GameDiagnosticsOverlay(session: session) + .padding(.top, titleInset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + ) + let overlay = UIWindowOverlayView(content: content) + overlay.frame = window.bounds + window.addSubview(overlay) + overlay.setNeedsLayout() + overlay.setNeedsDisplay() + } +} diff --git a/Sources/AdaEngine/Debug/GameDiagnosticsSession.swift b/Sources/AdaEngine/Debug/GameDiagnosticsSession.swift new file mode 100644 index 000000000..55c4d32fb --- /dev/null +++ b/Sources/AdaEngine/Debug/GameDiagnosticsSession.swift @@ -0,0 +1,68 @@ +import AdaApp +import AdaUtils +import Observation + +/// Measures the elapsed time between variable updates in a single game world. +@_spi(AdaEngine) +@MainActor +@Observable +public final class GameDiagnosticsSession { + struct Sample: Equatable { + let framesPerSecond: Double + let millisecondsPerFrame: Double + } + + private(set) var sample: Sample? + @ObservationIgnored private var windowStart: Double? + @ObservationIgnored private var previousTick: Double? + @ObservationIgnored private var intervalCount = 0 + + public init() {} + + public func attach(to app: AppWorlds) { + reset() + app.gameDiagnosticsTick = { [weak self] isRunning in + self?.tick(at: Time.absolute, isRunning: isRunning) + } + } + + func tick(at timestamp: Double, isRunning: Bool = true) { + guard isRunning else { + reset() + return + } + guard timestamp.isFinite else { + return + } + guard let windowStart, let previousTick else { + self.windowStart = timestamp + self.previousTick = timestamp + return + } + let elapsed = timestamp - windowStart + guard timestamp > previousTick, elapsed.isFinite else { + return + } + self.previousTick = timestamp + intervalCount += 1 + + guard elapsed >= 1 else { + return + } + sample = Sample( + framesPerSecond: Double(intervalCount) / elapsed, + millisecondsPerFrame: elapsed / Double(intervalCount) * 1_000 + ) + self.windowStart = timestamp + intervalCount = 0 + } + + private func reset() { + if sample != nil { + sample = nil + } + windowStart = nil + previousTick = nil + intervalCount = 0 + } +} diff --git a/Sources/AdaEngine/Scenes/DefaultAppWindow.swift b/Sources/AdaEngine/Scenes/DefaultAppWindow.swift index 184653633..65873ba8d 100644 --- a/Sources/AdaEngine/Scenes/DefaultAppWindow.swift +++ b/Sources/AdaEngine/Scenes/DefaultAppWindow.swift @@ -21,6 +21,11 @@ public struct DefaultAppWindow: AppScene { DefaultPlugins(filePath: filePath, assetBundle: assetBundle), after: MainSchedulerPlugin.self ) + #if os(macOS) || os(Windows) || os(Linux) + if ProcessInfo.processInfo.environment["ADAENGINE_DIAGNOSTICS_OVERLAY"] == "1" { + world.addPlugin(GameDiagnosticsPlugin()) + } + #endif } } diff --git a/Sources/AdaUI/CoreUI/UIWindowOverlayView.swift b/Sources/AdaUI/CoreUI/UIWindowOverlayView.swift new file mode 100644 index 000000000..95b6dab0c --- /dev/null +++ b/Sources/AdaUI/CoreUI/UIWindowOverlayView.swift @@ -0,0 +1,27 @@ +import AdaInput +import Math + +/// A passive window layer composited after the window's cameras. +package final class UIWindowOverlayView: UIView { + package init(content: UIView) { + super.init() + isInteractionEnabled = false + autoresizingRules = [.flexibleWidth, .flexibleHeight] + content.autoresizingRules = [.flexibleWidth, .flexibleHeight] + addSubview(content) + } + + package required init(frame: Rect) { + super.init(frame: frame) + isInteractionEnabled = false + } + + override package func canRespondToAction(_: any InputEvent) -> Bool { false } + + // The window compositor collects this layer separately from camera UI. + override package func draw(with _: UIGraphicsContext) {} + + func drawOverlay(with context: UIGraphicsContext) { + super.draw(with: context) + } +} diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Editing.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Editing.swift index 95315e211..bedabb409 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Editing.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Editing.swift @@ -62,7 +62,7 @@ extension TextEditorViewNode { } func moveCaretHorizontally(delta: Int, extendSelection: Bool) { - let characterCount = self.text.count + let characterCount = self.textCharacterCount if extendSelection { self.selectionHead = max(0, min(characterCount, self.caretOffset + delta)) @@ -143,7 +143,7 @@ extension TextEditorViewNode { func moveCaret(to offset: Int, extendSelection: Bool) { if extendSelection { - self.selectionHead = max(0, min(offset, self.text.count)) + self.selectionHead = max(0, min(offset, self.textCharacterCount)) } else { self.setSelection(to: offset) } @@ -276,7 +276,7 @@ extension TextEditorViewNode { return } - guard self.caretOffset < self.text.count else { + guard self.caretOffset < self.textCharacterCount else { return } @@ -299,7 +299,7 @@ extension TextEditorViewNode { func selectAll() { self.selectionAnchor = 0 - self.selectionHead = self.text.count + self.selectionHead = self.textCharacterCount self.ensureCaretVisibleIfNeeded() self.requestDisplay() } @@ -415,19 +415,24 @@ extension TextEditorViewNode { } func clampSelectionToBounds() { - let upperBound = self.text.count + let upperBound = self.textCharacterCount self.selectionAnchor = max(0, min(self.selectionAnchor, upperBound)) self.selectionHead = max(0, min(self.selectionHead, upperBound)) } func setSelection(to offset: Int) { - let clamped = max(0, min(offset, self.text.count)) + let clamped = max(0, min(offset, self.textCharacterCount)) self.selectionAnchor = clamped self.selectionHead = clamped } func index(forOffset offset: Int) -> String.Index { - let clamped = max(0, min(offset, self.text.count)) - return self.text.index(self.text.startIndex, offsetBy: clamped) + let lines = self.lines() + let clamped = max(0, min(offset, self.textCharacterCount)) + let line = lines[self.lineIndex(containingOffset: clamped, lines: lines)] + guard let lineStart = line.startIndex else { + return self.text.index(self.text.startIndex, offsetBy: clamped) + } + return self.text.index(lineStart, offsetBy: clamped - line.startOffset) } } diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift index 420760279..9472525ee 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift @@ -15,28 +15,33 @@ extension TextEditorViewNode { return lineCache } + // Split once per text revision. Each line records its character length and string index so + // offset conversions do not rescan the whole document on every keystroke or frame. var result: [LineInfo] = [] - var current = "" var startOffset = 0 - var offset = 0 - - for character in self.text { - if character == "\n" { - result.append(LineInfo(text: current, startOffset: startOffset)) - current = "" - offset += 1 - startOffset = offset - } else { - current.append(character) - offset += 1 - } + var lineStart = self.text.startIndex + for line in self.text.split(separator: "\n", omittingEmptySubsequences: false) { + let info = LineInfo(text: String(line), startOffset: startOffset, startIndex: lineStart) + result.append(info) + startOffset += info.length + 1 + lineStart = line.endIndex < self.text.endIndex ? self.text.index(after: line.endIndex) : self.text.endIndex + } + if result.isEmpty { + result.append(LineInfo(text: "", startOffset: 0, startIndex: self.text.startIndex)) } - - result.append(LineInfo(text: current, startOffset: startOffset)) self.lineCache = result + self.lineCacheBuilds += 1 return result } + /// Character count of the whole text, derived from the cached line table. + var textCharacterCount: Int { + guard let last = self.lines().last else { + return 0 + } + return last.startOffset + last.length + } + func invalidateTextCaches() { self.lineCache = nil self.wrappedRowsCache = nil @@ -50,21 +55,30 @@ extension TextEditorViewNode { self.markNeedsLayout() } - func position(forOffset offset: Int, lines: [LineInfo]) -> TextPosition { - let clamped = max(0, min(offset, self.text.count)) - var bestLine = 0 - - for index in lines.indices { - let line = lines[index] - let lineEndOffset = line.startOffset + line.text.count - if clamped <= lineEndOffset { - return TextPosition(line: index, column: clamped - line.startOffset) + /// Index of the line containing `offset`; line ends belong to their line, as before. + func lineIndex(containingOffset offset: Int, lines: [LineInfo]) -> Int { + var lower = 0 + var upper = lines.count + while lower < upper { + let middle = (lower + upper) / 2 + if lines[middle].startOffset <= offset { + lower = middle + 1 + } else { + upper = middle } - bestLine = index } + return max(0, lower - 1) + } - let fallback = lines[bestLine] - return TextPosition(line: bestLine, column: fallback.text.count) + func position(forOffset offset: Int, lines: [LineInfo]) -> TextPosition { + guard !lines.isEmpty else { + return TextPosition(line: 0, column: 0) + } + let last = lines[lines.count - 1] + let clamped = max(0, min(offset, last.startOffset + last.length)) + let index = self.lineIndex(containingOffset: clamped, lines: lines) + let line = lines[index] + return TextPosition(line: index, column: min(clamped - line.startOffset, line.length)) } func offset(line: Int, column: Int, lines: [LineInfo]) -> Int { @@ -73,7 +87,7 @@ extension TextEditorViewNode { } let lineIndex = max(0, min(line, lines.count - 1)) - return lines[lineIndex].startOffset + max(0, min(column, lines[lineIndex].text.count)) + return lines[lineIndex].startOffset + max(0, min(column, lines[lineIndex].length)) } func closestOffset(to point: Point) -> Int { diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Rendering.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Rendering.swift index 433d7d39e..caeb47dfa 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Rendering.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Rendering.swift @@ -260,16 +260,7 @@ extension TextEditorViewNode { in context: inout UIGraphicsContext, at point: Point ) { - let lineSpans = - tokenSpans - .filter { $0.line == lineIndex && $0.length > 0 } - .sorted { lhs, rhs in - if lhs.startColumn == rhs.startColumn { - lhs.length < rhs.length - } else { - lhs.startColumn < rhs.startColumn - } - } + let lineSpans = self.tokenSpans(forLine: lineIndex) guard !lineText.isEmpty else { return @@ -289,6 +280,42 @@ extension TextEditorViewNode { self.drawTextLayout(cached.layout, verticalOffset: cached.verticalOffset, in: &context, at: point) } + /// Token spans of one line, sorted by column. The per-line index is built once per span set, + /// so drawing visible rows does not scan every span in the document for each row. + func tokenSpans(forLine lineIndex: Int) -> [TextEditorTokenSpan] { + self.tokenSpanIndex().sorted[lineIndex] ?? [] + } + + /// Token spans of one line in their original order, including empty spans (wrapped rows draw these). + func documentOrderTokenSpans(forLine lineIndex: Int) -> [TextEditorTokenSpan] { + self.tokenSpanIndex().documentOrder[lineIndex] ?? [] + } + + private func tokenSpanIndex() -> TokenSpanLineIndex { + if let index = self.tokenSpansByLineCache { + return index + } + var index = TokenSpanLineIndex() + for span in self.tokenSpans { + index.documentOrder[span.line, default: []].append(span) + if span.length > 0 { + index.sorted[span.line, default: []].append(span) + } + } + for line in index.sorted.keys { + index.sorted[line]?.sort { lhs, rhs in + if lhs.startColumn == rhs.startColumn { + lhs.length < rhs.length + } else { + lhs.startColumn < rhs.startColumn + } + } + } + self.tokenSpansByLineCache = index + self.tokenSpanIndexBuilds += 1 + return index + } + func attributedLineText( _ lineText: String, lineSpans: [TextEditorTokenSpan], @@ -517,7 +544,11 @@ extension TextEditorViewNode { } static func normalizeInputText(_ value: String) -> String { - value.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + // Runs for every view update; most text has no carriage returns, so skip Foundation's rewrite. + guard value.utf8.contains(UInt8(ascii: "\r")) else { + return value + } + return value.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") } static func verticalTextOffset(for layout: TextLayoutManager, height: Float) -> Float { diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift index c96aaed17..1e21b474d 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift @@ -105,7 +105,7 @@ extension TextEditorViewNode { let local = convertPointFromRoot(touch.location) if let drag = session.handleDrag { selectionAnchor = drag.fixedOffset - selectionHead = drag.movingOffset(closestOffset(to: drag.caretPoint(for: local)), textCount: text.count) + selectionHead = drag.movingOffset(closestOffset(to: drag.caretPoint(for: local)), textCount: textCharacterCount) } else { selectionHead = closestOffset(to: local) } diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Wrapping.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Wrapping.swift index 12dca7f8a..30bfbaffa 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Wrapping.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Wrapping.swift @@ -153,7 +153,7 @@ extension TextEditorViewNode { } } if let font { - let spans = tokenSpans.filter { $0.line == row.sourceLine }.map { span in + let spans = documentOrderTokenSpans(forLine: row.sourceLine).map { span in TextEditorTokenSpan(line: span.line, startColumn: span.startColumn - row.startColumn, length: span.length, color: span.color, font: span.font) } let attributed = attributedLineText(row.line.text, lineSpans: spans, font: font, fallbackColor: resolvedTextColor()) diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift index dd775bfc5..6691a0de0 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift @@ -26,6 +26,22 @@ final class TextEditorViewNode: ViewNode { struct LineInfo { var text: String var startOffset: Int + /// Character count of `text`, measured once when the line table is built. + var length: Int + /// Position of the line in the editor text; nil for derived rows such as wrapped fragments. + var startIndex: String.Index? + + init(text: String, startOffset: Int, startIndex: String.Index? = nil) { + self.text = text + self.startOffset = startOffset + self.length = text.count + self.startIndex = startIndex + } + } + + struct TokenSpanLineIndex { + var sorted: [Int: [TextEditorTokenSpan]] = [:] + var documentOrder: [Int: [TextEditorTokenSpan]] = [:] } final class CachedTextLayout { @@ -63,7 +79,7 @@ final class TextEditorViewNode: ViewNode { var placeholder: String var textBinding: Binding - var tokenSpans: [TextEditorTokenSpan] + var tokenSpans: [TextEditorTokenSpan] { didSet { tokenSpansByLineCache = nil } } var sourceInteraction: TextEditorSourceInteraction? var showsLineNumbers: Bool var wrapsLines: Bool @@ -77,7 +93,15 @@ final class TextEditorViewNode: ViewNode { showsIndentationGuides || showsTabMarkers || showsSpaceMarkers } var highlightsSelectedIdentifier: Bool - var text: String { didSet { if oldValue != text { invalidateInputComposition() } } } + /// The line table holds indices into the previous text; drop it before any observer can read it. + var text: String { + didSet { + if oldValue != text { + lineCache = nil + invalidateInputComposition() + } + } + } var isFocused = false var isCursorActive = false @@ -123,6 +147,9 @@ final class TextEditorViewNode: ViewNode { var renderedLineCacheHits = 0 var renderedLineCacheMisses = 0 var lineCache: [LineInfo]? + var lineCacheBuilds = 0 + var tokenSpansByLineCache: TokenSpanLineIndex? + var tokenSpanIndexBuilds = 0 var foldRangesCache: [Int: Range]? var displayedLinesCache: [Int]? var collapsedFoldLines: Set = [] @@ -158,7 +185,7 @@ final class TextEditorViewNode: ViewNode { let lineHeight = self.lineHeight(for: pointSize) let lines = self.lines() let displayedLines = self.displayedLines() - let maxLineCharacterCount = wrapsLines ? 1 : max(displayedLines.map { lines[$0].text.count }.max() ?? 0, self.placeholder.count, 1) + let maxLineCharacterCount = wrapsLines ? 1 : max(displayedLines.map { lines[$0].length }.max() ?? 0, self.placeholder.count, 1) let maxLineWidth = Float(maxLineCharacterCount) * self.characterAdvance(for: pointSize) let ideal = Size( width: max(Constants.minimumWidth, Constants.horizontalInset * 2 + self.gutterInset + maxLineWidth), @@ -195,7 +222,9 @@ final class TextEditorViewNode: ViewNode { self.placeholder = node.placeholder self.textBinding = node.textBinding - self.tokenSpans = node.tokenSpans + if self.tokenSpans != node.tokenSpans { + self.tokenSpans = node.tokenSpans + } self.sourceInteraction = node.sourceInteraction self.showsLineNumbers = node.showsLineNumbers if wrapsLines != node.wrapsLines { diff --git a/Sources/AdaUI/Plugins/UIPlugin+Render.swift b/Sources/AdaUI/Plugins/UIPlugin+Render.swift index 1fa238f58..8abf86915 100644 --- a/Sources/AdaUI/Plugins/UIPlugin+Render.swift +++ b/Sources/AdaUI/Plugins/UIPlugin+Render.swift @@ -149,6 +149,16 @@ public func UIRenderPreparing( component.view.draw(with: context) context.commitDraw() contexts.graphicContexts.append(context) + if let window = component.view as? UIWindow { + for case let overlay as UIWindowOverlayView in window.subviews { + var overlayContext = UIGraphicsContext() + overlayContext.windowId = window.id + overlayContext.isWindowOverlay = true + overlay.drawOverlay(with: overlayContext) + overlayContext.commitDraw() + contexts.graphicContexts.append(overlayContext) + } + } } contexts.graphicContexts.append(contentsOf: extractedUIContexts.contexts) } @@ -215,7 +225,7 @@ public struct UIRenderTesselationSystem { // Flush current state to preserve draw order. if layerStack.isEmpty { flushStateIfNeeded(&rootState, renderDevice: renderDevice.renderDevice).map { rootState.drawDataItems.append($0) } - appendRenderItems(rootState.drawDataItems, sortKey: &sortKey, windowId: windowId) + appendRenderItems(rootState.drawDataItems, sortKey: &sortKey, context: graphicsContext) rootState.drawDataItems.removeAll(keepingCapacity: true) inheritedState = rootState } else if layerStack[layerStack.count - 1].mode == .building { @@ -223,7 +233,7 @@ public struct UIRenderTesselationSystem { .map { layerStack[layerStack.count - 1].state.drawDataItems.append($0) } - appendRenderItems(layerStack[layerStack.count - 1].state.drawDataItems, sortKey: &sortKey, windowId: windowId) + appendRenderItems(layerStack[layerStack.count - 1].state.drawDataItems, sortKey: &sortKey, context: graphicsContext) layerStack[layerStack.count - 1].state.drawDataItems.removeAll(keepingCapacity: true) inheritedState = layerStack[layerStack.count - 1].state } @@ -235,7 +245,7 @@ public struct UIRenderTesselationSystem { cached.cacheable, cached.clipRect == inheritedState.currentClipRect, cached.clipPolygons == inheritedState.currentClipPolygons { - appendRenderItems(cached.drawDataItems, sortKey: &sortKey, windowId: windowId) + appendRenderItems(cached.drawDataItems, sortKey: &sortKey, context: graphicsContext) layerStack.append(ActiveLayer(id: id, version: version, mode: .skipping, cacheable: cacheable, state: inheritedState)) } else { layerStack.append(ActiveLayer(id: id, version: version, mode: .building, cacheable: canUseLayerCache, state: inheritedState)) @@ -255,7 +265,7 @@ public struct UIRenderTesselationSystem { break case .building: flushStateIfNeeded(&layer.state, renderDevice: renderDevice.renderDevice).map { layer.state.drawDataItems.append($0) } - appendRenderItems(layer.state.drawDataItems, sortKey: &sortKey, windowId: windowId) + appendRenderItems(layer.state.drawDataItems, sortKey: &sortKey, context: graphicsContext) cacheLayer(layer, windowId: windowId) } @@ -289,7 +299,7 @@ public struct UIRenderTesselationSystem { renderDevice: renderDevice.renderDevice ) .map { rootState.drawDataItems.append($0) } - appendRenderItems(rootState.drawDataItems, sortKey: &sortKey, windowId: windowId) + appendRenderItems(rootState.drawDataItems, sortKey: &sortKey, context: graphicsContext) } if !layerDrawCache.entries.isEmpty { @@ -394,20 +404,20 @@ public struct UIRenderTesselationSystem { private func appendRenderItems( _ items: [UIDrawData], sortKey: inout Float, - windowId: WindowID? + context: UIGraphicsContext ) { for drawData in items { - self.renderItems.items.append( - UITransparentRenderItem( - sortKey: sortKey, - windowId: windowId, - entity: .zero, - drawPass: uiDrawPass, - primitiveType: .quad, - renderPipeline: renderPipelines, - drawData: drawData - ) + var item = UITransparentRenderItem( + sortKey: sortKey, + windowId: context.windowId, + entity: .zero, + drawPass: uiDrawPass, + primitiveType: .quad, + renderPipeline: renderPipelines, + drawData: drawData ) + item.isWindowOverlay = context.isWindowOverlay + self.renderItems.items.append(item) sortKey += 1 } } @@ -968,6 +978,7 @@ public struct UITransparentRenderItem: RenderItem { public var batchRange: Range? public var renderPipeline: UIRenderPipelines public var drawData: UIDrawData + var isWindowOverlay = false /// Type of UI primitive being rendered. public enum PrimitiveType: Sendable { diff --git a/Sources/AdaUI/Plugins/UIPlugin.swift b/Sources/AdaUI/Plugins/UIPlugin.swift index 9f17fe0b6..4e3951ab1 100644 --- a/Sources/AdaUI/Plugins/UIPlugin.swift +++ b/Sources/AdaUI/Plugins/UIPlugin.swift @@ -18,6 +18,15 @@ import Math public struct UIPlugin: Plugin { public init() {} + @MainActor + package static func installWindowOverlayPass(in app: AppWorlds) { + guard let renderWorld = app.getSubworldBuilder(by: .renderWorld) else { return } + let graph = renderWorld.getRefResource(RenderGraph.self) + let name: RenderNodeLabel = "PrimaryWindowOverlay" + graph.wrappedValue.addNode(UIRenderNode(isWindowOverlay: true), by: name) + graph.wrappedValue.addNodeEdge(from: .cameraRender, to: name) + } + public func setup(in app: AppWorlds) { UIComponent.registerComponent() diff --git a/Sources/AdaUI/Render/UIDrawPass.swift b/Sources/AdaUI/Render/UIDrawPass.swift index 5e70cbafd..56700506b 100644 --- a/Sources/AdaUI/Render/UIDrawPass.swift +++ b/Sources/AdaUI/Render/UIDrawPass.swift @@ -282,14 +282,15 @@ public struct UIDrawPass: DrawPass { item: UITransparentRenderItem ) throws { let uiDrawData = item.drawData - let renderBounds = resolveRenderBounds(world: world, view: view) - let viewportOrigin = resolveViewportOrigin(world: world, view: view) + let renderBounds = resolveRenderBounds(world: world, view: view, windowOutput: item.isWindowOverlay) + let viewportOrigin = item.isWindowOverlay ? .zero : resolveViewportOrigin(world: world, view: view) + let clipScale = item.isWindowOverlay ? .one : world.get(Camera.self, from: view.id).map(Self.clipScale(for:)) ?? .one switch resolveScissorDecision( clipRect: uiDrawData.clipRect, renderBounds: renderBounds, viewportOrigin: viewportOrigin, - clipScale: world.get(Camera.self, from: view.id).map(Self.clipScale(for:)) ?? .one + clipScale: clipScale ) { case .none: break @@ -650,8 +651,8 @@ public struct UIDrawPass: DrawPass { } } - private func resolveRenderBounds(world: World, view: Entity) -> Rect? { - if let target = world.get(RenderViewTarget.self, from: view.id), let texture = target.mainTexture { + private func resolveRenderBounds(world: World, view: Entity, windowOutput: Bool) -> Rect? { + if let target = world.get(RenderViewTarget.self, from: view.id), let texture = windowOutput ? target.outputTexture : target.mainTexture { if texture.width > 0, texture.height > 0 { return Rect(x: 0, y: 0, width: Float(texture.width), height: Float(texture.height)) } diff --git a/Sources/AdaUI/Render/UIGraphicsContext.swift b/Sources/AdaUI/Render/UIGraphicsContext.swift index 9086318fd..9ad3cda30 100644 --- a/Sources/AdaUI/Render/UIGraphicsContext.swift +++ b/Sources/AdaUI/Render/UIGraphicsContext.swift @@ -67,6 +67,8 @@ public struct UIGraphicsContext: Sendable { /// Window this draw context belongs to. A nil value renders into every window camera. public var windowId: WindowID? + var isWindowOverlay = false + private(set) var commandQueue = CommandQueue() /// Create graphics context. diff --git a/Sources/AdaUI/Render/UIRenderNode.swift b/Sources/AdaUI/Render/UIRenderNode.swift index c46473d25..75ab058b7 100644 --- a/Sources/AdaUI/Render/UIRenderNode.swift +++ b/Sources/AdaUI/Render/UIRenderNode.swift @@ -7,7 +7,7 @@ import AdaCorePipelines import AdaECS -import AdaRender +@_spi(Internal) import AdaRender import AdaUtils import Math @@ -23,8 +23,8 @@ public struct UIViewUniform: Resource { mutating func retainViews(_ views: [Entity.ID]) { bufferedViews.retainViews(views) } - mutating func write(_ uniform: GlobalViewUniform, view: Entity.ID, device: RenderDevice) -> BufferData { - bufferedViews.write(uniform, view: view, device: device) + mutating func write(_ uniform: GlobalViewUniform, view: Entity.ID, pass: Int, device: RenderDevice) -> BufferData { + bufferedViews.write(uniform, view: view, pass: pass, device: device) } } @@ -58,11 +58,17 @@ public struct UIRenderNode: RenderNode { @ResMut private var glassBackground - public init() {} + private let isWindowOverlay: Bool + + public init() { self.init(isWindowOverlay: false) } + + init(isWindowOverlay: Bool) { + self.isWindowOverlay = isWindowOverlay + } - public let inputResources: [RenderSlot] = [ - RenderSlot(name: InputNode.view, kind: .entity) - ] + public var inputResources: [RenderSlot] { + isWindowOverlay ? [] : [RenderSlot(name: InputNode.view, kind: .entity)] + } public func update(from world: World) { query.update(from: world) @@ -80,26 +86,30 @@ public struct UIRenderNode: RenderNode { context: inout Context, renderContext: AdaRender.RenderContext ) async throws -> [AdaRender.RenderSlotValue] { - guard let view = context.viewEntity else { + let view = context.viewEntity + guard isWindowOverlay || view != nil else { return [] } + var drewWindowOverlay = false try query.forEach { entity, camera, target, _, source in - if entity != view { - return - } - - guard let mainTexture = target.mainTexture else { + let targetWindowId = camera.targetWindowId(from: primaryWindowId) + if isWindowOverlay { + guard !drewWindowOverlay, camera.isActive, targetWindowId == primaryWindowId.windowId else { return } + } else if entity != view { return } - let targetWindowId = camera.targetWindowId(from: primaryWindowId) + guard let mainTexture = isWindowOverlay ? target.outputTexture : target.mainTexture else { return } // A pixel-perfect camera drew its scene at a low resolution. The UI is not part of that picture: the scene is // scaled into a picture of the output's size first, and the UI is drawn onto it at full resolution. var texture: RenderTexture = mainTexture - var uiViewport = camera.viewport.rect + var uiViewport = isWindowOverlay + ? Rect(x: 0, y: 0, width: Float(texture.width), height: Float(texture.height)) + : camera.viewport.rect var pixelPerfectPresent: (layout: PixelPerfectLayout, settings: PixelPerfectSettings, pictures: PixelPerfectPictures, pipelines: PixelPerfectPipelines)? if + !isWindowOverlay, let settings = target.pixelPerfect, let output = target.outputTexture, let pipelines = context.world.getResource(PixelPerfectPipelines.self), @@ -115,9 +125,9 @@ public struct UIRenderNode: RenderNode { let texHeight = texture.height // Snapshot main target for glass (must complete before glass fragments sample it). - if glassBackground.texture == nil + if !isWindowOverlay && (glassBackground.texture == nil || glassBackground.texture?.width != texWidth - || glassBackground.texture?.height != texHeight { + || glassBackground.texture?.height != texHeight) { glassBackground.texture = RenderTexture( size: SizeInt(width: texWidth, height: texHeight), scaleFactor: texture.scaleFactor, @@ -152,7 +162,9 @@ public struct UIRenderNode: RenderNode { let viewportSize = uiViewport.size let logicalViewportSize = camera.logicalViewport.rect.size let effectiveScaleFactor: Float - if logicalViewportSize.width > 0 { + if isWindowOverlay { + effectiveScaleFactor = camera.computedData.targetScaleFactor + } else if logicalViewportSize.width > 0 { effectiveScaleFactor = viewportSize.width / logicalViewportSize.width } else { effectiveScaleFactor = texture.scaleFactor @@ -169,7 +181,9 @@ public struct UIRenderNode: RenderNode { let uiViewUniform = makeUIViewUniform( projection: uiProjection ) - let viewConstants = self.uiViewUniform.write(uiViewUniform, view: source.entityId, device: renderDevice.renderDevice) + let viewConstants = self.uiViewUniform.write( + uiViewUniform, view: source.entityId, pass: isWindowOverlay ? 1 : 0, device: renderDevice.renderDevice + ) let uiRenderPassDescriptor = RenderPassDescriptor( label: "UI Render Pass", @@ -223,7 +237,8 @@ public struct UIRenderNode: RenderNode { var renderPass: RenderCommandEncoder? - for item in renderItems.items where item.windowId == nil || item.windowId == targetWindowId { + for item in renderItems.items + where item.isWindowOverlay == isWindowOverlay && (item.windowId == nil || item.windowId == targetWindowId) { let itemUsesGlass = !item.drawData.glassIndexBuffer.isEmpty if itemUsesGlass { @@ -249,7 +264,7 @@ public struct UIRenderNode: RenderNode { .render( with: activePass, world: context.world, - view: view, + view: entity, item: item ) } @@ -257,6 +272,7 @@ public struct UIRenderNode: RenderNode { renderPass?.endRenderPass() commandBuffer.commit() + drewWindowOverlay = isWindowOverlay } return [] diff --git a/Tests/AdaEngineTests/GameDiagnosticsSessionTests.swift b/Tests/AdaEngineTests/GameDiagnosticsSessionTests.swift new file mode 100644 index 000000000..1ced8401f --- /dev/null +++ b/Tests/AdaEngineTests/GameDiagnosticsSessionTests.swift @@ -0,0 +1,47 @@ +@_spi(AdaEngine) @testable import AdaEngine +import Testing + +@Suite("Game diagnostics") +@MainActor +struct GameDiagnosticsSessionTests { + @Test func publishesBothMetricsFromTheSameWindow() throws { + let session = GameDiagnosticsSession() + for timestamp in [0, 0.25, 0.5, 0.75] { + session.tick(at: timestamp) + #expect(session.sample == nil) + } + session.tick(at: 1) + let sample = try #require(session.sample) + #expect(sample.framesPerSecond == 4) + #expect(sample.millisecondsPerFrame == 250) + + // The gap between 1.25 and 2.25 is an active-game stall. + session.tick(at: 1.25) + #expect(session.sample == sample) + session.tick(at: 2.25) + let next = try #require(session.sample) + #expect(next.framesPerSecond == 1.6) + #expect(next.millisecondsPerFrame == 625) + } + + @Test func invalidTicksAndPausesDoNotSkewTheNextSample() throws { + let session = GameDiagnosticsSession() + session.tick(at: 10) + session.tick(at: 10) + session.tick(at: .infinity) + session.tick(at: 9) + session.tick(at: 11) + #expect(session.sample?.framesPerSecond == 1) + + session.tick(at: 12, isRunning: false) + #expect(session.sample == nil) + session.tick(at: 30) + session.tick(at: 30.5) + #expect(session.sample == nil) + session.tick(at: 31) + let sample = try #require(session.sample) + #expect(sample.framesPerSecond == 2) + #expect(sample.millisecondsPerFrame == 500) + #expect(GameDiagnosticsSession().sample == nil) + } +} diff --git a/Tests/AdaUITests/TextEditorLargeDocumentTests.swift b/Tests/AdaUITests/TextEditorLargeDocumentTests.swift new file mode 100644 index 000000000..05b9e91a7 --- /dev/null +++ b/Tests/AdaUITests/TextEditorLargeDocumentTests.swift @@ -0,0 +1,121 @@ +import AdaInput +@testable import AdaPlatform +import AdaText +@_spi(Internal) @testable import AdaUI +import AdaUtils +import Foundation +import Math +import Testing + +/// Large documents must not rescan the whole text or every token span for each keystroke or visible row. +@MainActor +struct TextEditorLargeDocumentTests { + init() async throws { + try Application.prepareForTest() + } + + @Test + func tokenSpansAreIndexedOncePerSpanSet() throws { + let lineCount = 2_000 + let text = (0.. Date: Tue, 10 Nov 2026 08:13:27 +0300 Subject: [PATCH 3/4] Update Ada Studio editor, agent tooling and contributor guide Editor project files, build targets, scene YAML payload decoding, code highlighting state, agent MCP and Windows ACP integration, localization, and the ada_tool CLI with its tests. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- Editor/Documentation/AgentGuides/scenes.md | 7 +- Editor/Documentation/AgentGuides/workbench.md | 7 + Editor/Package.resolved | 10 +- .../Agent/EditorAgentMCPConnection.swift | 2 +- .../Agent/EditorWindowsAgentCommand.swift | 18 +++ .../AdaEditor/Assets/Localization/en.json | 5 + .../AdaEditor/Assets/Localization/ru.json | 5 + .../Sources/AdaEditor/EditorSceneModel.swift | 3 + .../EditorSceneYAMLPayloadDecoder.swift | 148 ++++++++++++++++++ Editor/Sources/AdaEditor/ProjectSystem.swift | 18 ++- .../AdaEditor/Tooling/SwiftTooling.swift | 9 +- .../EditorAdaScriptProjectRuntimeView.swift | 16 +- .../UI/Editor/EditorBuildTargetsModel.swift | 5 +- .../UI/Editor/EditorBuildTargetsView.swift | 20 +++ .../UI/Editor/EditorCodeFileView.swift | 8 +- .../UI/Editor/EditorCodeHighlightState.swift | 113 +++++++++++++ .../UI/Editor/EditorViewModel+Commands.swift | 22 ++- .../Editor/EditorViewModel+ProjectFiles.swift | 2 + .../UI/Editor/EditorViewModel+Workspace.swift | 2 + .../EditorWorkbenchViewModel+Documents.swift | 8 +- .../EditorAgentMCPConnectionTests.swift | 1 + .../EditorBuildTargetsTests.swift | 1 + .../EditorCodeHighlightStateTests.swift | 91 +++++++++++ .../EditorSceneYAMLPayloadDecoderTests.swift | 145 +++++++++++++++++ ...ditorTextInteractionPerformanceTests.swift | 48 ++++++ .../EditorWindowsACPTests.swift | 24 +++ script/ada_tools/cli.py | 20 ++- script/test_ada_tool.py | 41 +++++ 29 files changed, 770 insertions(+), 33 deletions(-) create mode 100644 Editor/Sources/AdaEditor/EditorSceneYAMLPayloadDecoder.swift create mode 100644 Editor/Sources/AdaEditor/UI/Editor/EditorCodeHighlightState.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorCodeHighlightStateTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorSceneYAMLPayloadDecoderTests.swift diff --git a/AGENTS.md b/AGENTS.md index 9a84b6fc1..7a3b8f9de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,9 @@ Game-authored UI follows its own art direction unless the task explicitly adopts `--launch` opens it after installation. Defaults are per-user, including `~/Applications` on macOS. - `python ada_tool.py build studio|engine` builds without installing; `--debug`, `--jobs N`, `--scratch-path PATH` and `--swift PATH` select the build configuration/tools. - `python ada_tool.py run studio [--debug]` opens an installed distribution. + `python ada_tool.py run studio [--debug|--profile]` opens an installed distribution. + `--profile` (build/install/run) is release plus debug symbols in a separate cache and + installation, for CPU profilers (WPA/Superluminal on Windows, Instruments, perf). - `python ada_tool.py build playground --cloud-panels PATH` builds the existing DOM shell; the panels must first be built in adawebsite with `npm run build:panels`. `python ada_tool.py build wasm --output dist/web` exports DOM Studio and AdaWebPlayer, diff --git a/Editor/Documentation/AgentGuides/scenes.md b/Editor/Documentation/AgentGuides/scenes.md index 730bf5eff..d21d0e4cc 100644 --- a/Editor/Documentation/AgentGuides/scenes.md +++ b/Editor/Documentation/AgentGuides/scenes.md @@ -50,7 +50,12 @@ or replacing the retained scene releases it. Hidden hierarchies do not prepare r Preserve the one-scene-per-pane bound and code input ownership when changing tabs. Lifecycle coverage: `EditorWorkbenchSplitTests.retainedScenePresentation` and `EditorScenePerformanceRegressionTests.retainedScenePausesHostedFrames`. -Large scene documents decode off the UI actor; previews prepare model/LOD/Spine +Large scene documents decode off the UI actor. YAML scenes parse once and convert +component payloads directly with Yams' scalar constructors (`EditorSceneYAMLPayloadDecoder`); +only the envelope uses `YAMLDecoder`, and unexpected shapes fall back to it. Do not send +mesh/physics payloads through `StudioValue`'s Codable probes. Coverage: +`EditorSceneYAMLPayloadDecoderTests`; opt-in timing: `ADAEDITOR_PERF_SCENE=`. +Previews prepare model/LOD/Spine assets asynchronously and install entities in batches while hosted frames are suspended. They retain shared handles per preview world, cancel stale loads and reuse unchanged imports. JSON component arrays use the portable value parser; edit worlds omit physics-body diff --git a/Editor/Documentation/AgentGuides/workbench.md b/Editor/Documentation/AgentGuides/workbench.md index 74787c05a..b0275dddf 100644 --- a/Editor/Documentation/AgentGuides/workbench.md +++ b/Editor/Documentation/AgentGuides/workbench.md @@ -27,6 +27,13 @@ This inventory was originally recorded on **2026-10-04** and subsequently mainta Live text selections are kept outside the observed document array; command snapshots merge them through `textDocument(id:)` and pane/active accessors. Caret geometry is observed by the completion overlay, not the whole code view. + Code views highlight sources over 24 KB off the UI actor (`EditorCodeHighlightState`): + edits keep the previous spans until one coalesced pass for the latest text finishes; + smaller sources stay synchronous. TextEditor splits lines once per text revision, + stores per-line lengths/indices for offset conversion, and indexes token spans by + line once per span set; never filter all spans per drawn row. Coverage: + `EditorCodeHighlightStateTests` and `TextEditorLargeDocumentTests`; opt-in timing: + `ADAEDITOR_PERF_CODE=1` (`largeSwiftFileTypingBenchmark`). Consecutive captured text-selection moves coalesce within a UI input batch; other drags and the raw scene input stream retain every event. Coverage: `EditorTextInteractionPerformanceTests` and `EditorWindowHoverInputTests`. diff --git a/Editor/Package.resolved b/Editor/Package.resolved index 485d93879..20af18da6 100644 --- a/Editor/Package.resolved +++ b/Editor/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4ce5d0c0775c013a6a6c73992639336696e7dde8bb108235454eef51a319814f", + "originHash" : "afca7230e7891a8cae45b780875ca14e36790c2114a2dbefbbf2a421cdbea013", "pins" : [ { "identity" : "h", @@ -171,14 +171,6 @@ "version" : "0.25.0" } }, - { - "identity" : "swiftterm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm.git", - "state" : { - "revision" : "caa1bdd3f1396c5b0ed66121f299d1c8b42bc94c" - } - }, { "identity" : "tree-sitter", "kind" : "remoteSourceControl", diff --git a/Editor/Sources/AdaEditor/Agent/EditorAgentMCPConnection.swift b/Editor/Sources/AdaEditor/Agent/EditorAgentMCPConnection.swift index b5c86babd..481ff4e43 100644 --- a/Editor/Sources/AdaEditor/Agent/EditorAgentMCPConnection.swift +++ b/Editor/Sources/AdaEditor/Agent/EditorAgentMCPConnection.swift @@ -18,7 +18,7 @@ import FoundationNetworking return [] } if supportsHTTP { - return [.http(.init(name: "AdaEditor", url: EditorMCPServerAddress.url))] + return [.http(.init(name: "AdaEditor", url: EditorMCPServerAddress.url, headers: []))] } // ACP requires stdio support. Reuse this executable as a bridge to the live // editor instead of launching another GUI with a separate project/undo history. diff --git a/Editor/Sources/AdaEditor/Agent/EditorWindowsAgentCommand.swift b/Editor/Sources/AdaEditor/Agent/EditorWindowsAgentCommand.swift index 564806032..bd80aba15 100644 --- a/Editor/Sources/AdaEditor/Agent/EditorWindowsAgentCommand.swift +++ b/Editor/Sources/AdaEditor/Agent/EditorWindowsAgentCommand.swift @@ -42,11 +42,29 @@ enum EditorWindowsAgentCommand { return try node(script: script, arguments: arguments, paths: [directory.path] + paths) } } + if let executable = nativeNpmExecutable(in: source, relativeTo: directory) { + return (executable, arguments) + } throw EditorAgentCatalogError(message: "Select the agent's .exe or its Node.js entry point. This batch file is not an npm launcher: \(path)") } return (url, arguments) } + private static func nativeNpmExecutable(in source: String, relativeTo directory: URL) -> URL? { + // Native global npm shims forward argv directly to an exe in node_modules. + let pattern = #"(?im)^[ \t]*@?"%dp0%[\\/](node_modules[\\/][^"%:\r\n]+\.exe)"[ \t]+%\*[ \t]*$"# + guard let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch(in: source, range: NSRange(source.startIndex..., in: source)), + let range = Range(match.range(at: 1), in: source) else { return nil } + let relativePath = source[range].replacingOccurrences(of: "\\", with: "/") + guard relativePath.split(separator: "/").allSatisfy({ $0 != "." && $0 != ".." }) else { return nil } + let executable = directory.appendingPathComponent(relativePath).standardizedFileURL + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: executable.path, isDirectory: &isDirectory), + !isDirectory.boolValue else { return nil } + return executable + } + private static func node(script: URL, arguments: [String], paths: [String]) throws -> (executable: URL, arguments: [String]) { guard let node = EditorAgentDiscovery.executable("node", paths: paths) else { throw EditorAgentCatalogError(message: "Install Node.js, then Refresh to launch this agent.") diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json index aa0468196..5a5d530ad 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/en.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json @@ -1392,6 +1392,11 @@ "Run": "Run", "Run All Tests": "Run All Tests", "Run Destination": "Run Destination", + "Diagnostics overlay": "Diagnostics overlay", + "Shows FPS and frame time the next time you run this target on desktop.": "Shows FPS and frame time the next time you run this target on desktop.", + "Available for local desktop runs.": "Available for local desktop runs.", + "Frame time": "Frame time", + "ms": "ms", "Run Products": "Run Products", "Run Selected": "Run Selected", "Run Tests": "Run Tests", diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json index c9cb50c6a..ccf64e4ec 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json @@ -1392,6 +1392,11 @@ "Run": "Запустить", "Run All Tests": "Запустить все тесты", "Run Destination": "Где запускать", + "Diagnostics overlay": "Панель диагностики", + "Shows FPS and frame time the next time you run this target on desktop.": "Показывает FPS и время кадра при следующем запуске этого таргета на компьютере.", + "Available for local desktop runs.": "Доступно при локальном запуске на компьютере.", + "Frame time": "Время кадра", + "ms": "мс", "Run Products": "Запуск программ", "Run Selected": "Запустить выбранное", "Run Tests": "Запустить тесты", diff --git a/Editor/Sources/AdaEditor/EditorSceneModel.swift b/Editor/Sources/AdaEditor/EditorSceneModel.swift index 030bfa19a..5e4afb234 100644 --- a/Editor/Sources/AdaEditor/EditorSceneModel.swift +++ b/Editor/Sources/AdaEditor/EditorSceneModel.swift @@ -161,6 +161,9 @@ struct EditorSceneModel: Codable, Equatable, Sendable { let model = try? decodeJSON(Data(content.utf8)) { return model } + if let model = try? EditorSceneYAMLPayloadDecoder.decode(content) { + return model + } return try YAMLDecoder(encoding: .utf8).decode(Self.self, from: content) } diff --git a/Editor/Sources/AdaEditor/EditorSceneYAMLPayloadDecoder.swift b/Editor/Sources/AdaEditor/EditorSceneYAMLPayloadDecoder.swift new file mode 100644 index 000000000..8f80dcbe0 --- /dev/null +++ b/Editor/Sources/AdaEditor/EditorSceneYAMLPayloadDecoder.swift @@ -0,0 +1,148 @@ +import AdaStudioCore +import Foundation +import Yams + +/// Decodes YAML scenes without sending component payloads through `Codable`. +/// +/// `StudioValue.init(from:)` probes Bool, Int, UInt64, Double and String for every scalar; through +/// `YAMLDecoder` each failed probe builds a `DecodingError`. Scenes with physics or mesh data hold +/// millions of scalars, which made opening them take minutes. This path parses the document once, +/// converts component payloads with the same Yams constructors in the same order, and decodes only the +/// remaining envelope (scene metadata, entity identity, editor state) with `YAMLDecoder`. +enum EditorSceneYAMLPayloadDecoder { + /// The document shape this path does not handle; callers fall back to the full `YAMLDecoder` path. + struct Unsupported: Error {} + + private static let mergeOnlyResolver = Resolver.basic.appending(.merge) + + static func decode(_ content: String) throws -> EditorSceneModel { + // Same parser configuration as `YAMLDecoder.decode(_:from: String)`: a merge-only resolver. + let parser = try Parser(yaml: content, resolver: mergeOnlyResolver, encoding: .utf8) + return try withExtendedLifetime(parser) { + guard let root = try parser.singleRoot(), case var .mapping(document) = root, + case var .sequence(entities)? = document["entities"] else { + throw Unsupported() + } + var payloads: [[String: EditorComponentPayload]] = [] + payloads.reserveCapacity(entities.count) + for index in entities.indices { + guard case var .mapping(entity) = entities[index], case let .mapping(components)? = entity["components"] else { + throw Unsupported() + } + var typed: [String: EditorComponentPayload] = [:] + for (name, component) in try keyedValues(components) { + guard case let .mapping(payload) = component else { + throw Unsupported() + } + typed[name] = try keyedValues(payload).mapValues(value) + } + payloads.append(typed) + entity["components"] = .mapping([:]) + entities[index] = .mapping(entity) + } + document["entities"] = .sequence(entities) + var model = try YAMLDecoder(encoding: .utf8).decode(EditorSceneModel.self, from: Node.mapping(document)) + guard model.entities.count == payloads.count else { + throw Unsupported() + } + for index in model.entities.indices { + model.entities[index].components = payloads[index] + } + return model + } + } + + /// Mirrors Yams' keyed container used by `Dictionary.init(from:)`: keys come from each key + /// node's string, and each value is the one `Node.Mapping[string]` resolves for that key. + private static func keyedValues(_ source: Node.Mapping) throws -> [String: Node] { + let needsFlattening = source.keys.contains { key in + let tag = Tag.Name(rawValue: key.tag.rawValue) + return tag == .merge || tag == .value + } + let mapping = needsFlattening ? Node.Mapping(try flattened(source), source.tag, source.style) : source + var result: [String: Node] = [:] + result.reserveCapacity(mapping.count) + for key in mapping.keys { + guard let name = key.string else { + continue + } + // Yams' keyed container hides its anchor/tag marker keys; leave such payloads to `YAMLDecoder`. + guard name != "yamlAnchor", name != "yamlTag", let node = mapping[name] else { + throw Unsupported() + } + result[name] = node + } + return result + } + + /// Mirrors Yams' internal `Node.Mapping.flatten()`, applied by every keyed container: `<<` merge + /// sources come first (sequence sources in reverse), so the mapping's own keys win on lookup. + private static func flattened(_ mapping: Node.Mapping) throws -> [(Node, Node)] { + var own: [(Node, Node)] = [] + var merged: [(Node, Node)] = [] + for pair in mapping { + switch Tag.Name(rawValue: pair.key.tag.rawValue) { + case .merge: + switch pair.value { + case let .mapping(source): + merged += try flattened(source) + case let .sequence(sources): + for source in sources.compactMap(\.mapping).reversed() { + merged += try flattened(source) + } + default: + break + } + case .value: + // Yams rewrites these key tags in place; leave such documents to `YAMLDecoder`. + throw Unsupported() + default: + own.append((pair.key, pair.value)) + } + } + return merged + own + } + + /// Mirrors `StudioValue.init(from:)` for a `YAMLDecoder` single-value container. + static func value(_ node: Node) throws -> StudioValue { + switch node { + case let .scalar(scalar): + return try scalarValue(scalar) + case let .sequence(sequence): + var values: [StudioValue] = [] + values.reserveCapacity(sequence.count) + for element in sequence { + values.append(try value(element)) + } + return .array(values) + case let .mapping(mapping): + return .object(try keyedValues(mapping).mapValues(value)) + case .alias: + throw Unsupported() + } + } + + /// Same probe order as `StudioValue.init(from:)`. `decodeNil()` is `node.null`, which uses + /// `NSNull.construct(from:)` directly. + private static func scalarValue(_ scalar: Node.Scalar) throws -> StudioValue { + if NSNull.construct(from: scalar) != nil { + return .null + } else if let value = construct(Bool.self, scalar) { + return .bool(value) + } else if let value = construct(Int.self, scalar) { + return .int(value) + } else if let value = construct(UInt64.self, scalar) { + return .uint(value) + } else if let value = construct(Double.self, scalar) { + return .double(value) + } else if let value = construct(String.self, scalar) { + return .string(value) + } + throw Unsupported() + } + + /// Generic so each type uses its `ScalarConstructible` witness, exactly as `YAMLDecoder` does. + private static func construct(_: T.Type, _ scalar: Node.Scalar) -> T? { + T.construct(from: scalar) + } +} diff --git a/Editor/Sources/AdaEditor/ProjectSystem.swift b/Editor/Sources/AdaEditor/ProjectSystem.swift index 0b3acfc32..fec87e333 100644 --- a/Editor/Sources/AdaEditor/ProjectSystem.swift +++ b/Editor/Sources/AdaEditor/ProjectSystem.swift @@ -630,22 +630,25 @@ public struct AdaProjectRun: Codable, Equatable, Sendable { public var arguments: [String] public var environment: [String: String] public var workingDirectory: String? + public var diagnosticsOverlay: Bool public init( destination: AdaProjectRunDestination = .macOS, executable: String? = nil, arguments: [String] = [], environment: [String: String] = [:], - workingDirectory: String? = nil + workingDirectory: String? = nil, + diagnosticsOverlay: Bool = false ) { self.destination = destination self.executable = executable self.arguments = arguments self.environment = environment self.workingDirectory = workingDirectory + self.diagnosticsOverlay = diagnosticsOverlay } - private enum CodingKeys: String, CodingKey { case destination, executable, arguments, environment, workingDirectory } + private enum CodingKeys: String, CodingKey { case destination, executable, arguments, environment, workingDirectory, diagnosticsOverlay } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -654,6 +657,7 @@ public struct AdaProjectRun: Codable, Equatable, Sendable { arguments = try container.decodeIfPresent([String].self, forKey: .arguments) ?? [] environment = try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:] workingDirectory = try container.decodeIfPresent(String.self, forKey: .workingDirectory) + diagnosticsOverlay = try container.decodeIfPresent(Bool.self, forKey: .diagnosticsOverlay) ?? false } public func encode(to encoder: Encoder) throws { @@ -663,6 +667,9 @@ public struct AdaProjectRun: Codable, Equatable, Sendable { try container.encode(arguments, forKey: .arguments) try container.encode(environment, forKey: .environment) try container.encodeIfPresent(workingDirectory, forKey: .workingDirectory) + if diagnosticsOverlay { + try container.encode(diagnosticsOverlay, forKey: .diagnosticsOverlay) + } } } @@ -674,6 +681,13 @@ public enum AdaProjectRunDestination: String, Codable, CaseIterable, Equatable, case visionOS = "visionos" case web case android + + var supportsDiagnosticsOverlay: Bool { + switch self { + case .macOS, .windows, .linux: true + case .iPadOS, .visionOS, .web, .android: false + } + } } public struct AdaProjectEditor: Codable, Equatable, Sendable { diff --git a/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift b/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift index 3342111fd..535b22b50 100644 --- a/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift +++ b/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift @@ -418,7 +418,7 @@ enum SwiftPMCommandKind: Equatable, Sendable { case resolve case describe case build(target: String?, buildTests: Bool) - case run(target: String?, arguments: [String]) + case run(target: String?, arguments: [String], diagnosticsOverlay: Bool = false) case runWeb(target: String, outputPath: String, serve: Bool) case test(filter: String?, eventStreamPath: String? = nil) case update @@ -619,7 +619,7 @@ actor SwiftPMWorkspaceService: SwiftPMWorkspaceServicing { ["package", "describe", "--type", "json"] case let .build(target, buildTests): buildArguments(target: target, buildTests: buildTests) - case let .run(target, arguments): + case let .run(target, arguments, _): runArguments(target: target, runArguments: arguments, configuration: configuration) case let .runWeb(target, outputPath, serve): webRunArguments(target: target, outputPath: outputPath, serve: serve) @@ -646,7 +646,10 @@ actor SwiftPMWorkspaceService: SwiftPMWorkspaceServicing { let platform: EditorBuildPlatform if case .runWeb = kind { platform = .web } else { platform = .host } var environment = buildSettings.environment(for: platform) - if case .run = kind { environment.merge(project?.run.environment ?? [:]) { _, value in value } } + if case let .run(_, _, diagnosticsOverlay) = kind { + environment.merge(project?.run.environment ?? [:]) { _, value in value } + environment["ADAENGINE_DIAGNOSTICS_OVERLAY"] = diagnosticsOverlay ? "1" : "0" + } if case .runWeb = kind { environment["ADAENGINE_WEB_EXPORT"] = "1" environment["BUILD_WASM"] = "1" diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift index 8fd572a1b..7564b1c02 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift @@ -26,6 +26,7 @@ struct EditorAdaScriptProjectRuntimeView: View { private let scriptPlugin: AdaScriptPlugin? private let objectCatalog: AdaScriptObjectCatalog private let directories: ProjectDirectories + private let diagnosticsSession: GameDiagnosticsSession? @State private var overlaySession = EditorSceneUIOverlaySession() var diagnostics: [String] { (scriptPlugin?.diagnostics ?? []) + objectCatalog.diagnostics } @@ -33,11 +34,13 @@ struct EditorAdaScriptProjectRuntimeView: View { init( artifact: EditorAdaScriptProjectBuildArtifact, controls: EditorGameWindowControls? = nil, - previewCapture: EditorProjectPreviewCapture? = nil + previewCapture: EditorProjectPreviewCapture? = nil, + diagnosticsOverlay: Bool = false ) throws { self.artifact = artifact self.controls = controls self.previewCapture = previewCapture + diagnosticsSession = diagnosticsOverlay ? GameDiagnosticsSession() : nil directories = try artifact.assetDirectories() EditorComponentRegistry.registerBuiltIns() // Scriptable UI resolves resource types before the world's plugins start. @@ -85,6 +88,16 @@ struct EditorAdaScriptProjectRuntimeView: View { .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .allowsHitTesting(controls?.inputEnabled ?? true) } + + if let diagnosticsSession { + GameDiagnosticsOverlay( + session: diagnosticsSession, + frameTimeLabel: L10n.text("Frame time"), + millisecondsUnit: L10n.text("ms") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .allowsHitTesting(false) + } } .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) } @@ -105,6 +118,7 @@ struct EditorAdaScriptProjectRuntimeView: View { /// Installs the game's plugins and scene. Shared by the Play window and offscreen automation runs. func configureRuntime(_ app: inout AppWorlds, includesEntryScene: Bool = true) { + diagnosticsSession?.attach(to: app) performanceSession.attach(app, title: artifact.report.entryDescription) app.addPlugin(TransformPlugin()) app.addPlugin(InputPlugin(actions: artifact.scenePlayRuntime.inputActions)) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsModel.swift index d570db912..a9fa14b64 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsModel.swift @@ -61,7 +61,10 @@ final class EditorBuildTargetsModel { suffix += 1 } target.name = name - if !copy { target.build.configuration = "debug" } + if !copy { + target.build.configuration = "debug" + target.run.diagnosticsOverlay = false + } project.buildTargets.append(target) project.activeBuildTargetID = target.id target.apply(to: &project) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsView.swift index 958d70c64..5ac5d3cf7 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorBuildTargetsView.swift @@ -143,6 +143,7 @@ struct EditorBuildTargetsDialog: View { } } } + diagnosticsOverlaySetting if model.draft?.build.system == .swiftpm { field("Executable Product", id: "Product", text: Binding(get: { model.productText }, set: { value in model.productText = value @@ -185,6 +186,25 @@ struct EditorBuildTargetsDialog: View { }.frame(maxWidth: .infinity, alignment: .leading) } + private var diagnosticsOverlaySetting: some View { + let supportsOverlay = model.editedTarget?.run.destination.supportsDiagnosticsOverlay == true + return VStack(alignment: .leading, spacing: 5) { + Toggle(L10n.text("Diagnostics overlay"), isOn: Binding( + get: { model.editedTarget?.run.diagnosticsOverlay ?? false }, + set: { enabled in model.edit { $0.run.diagnosticsOverlay = enabled } } + )) + .toggleStyle(.editorSettings(colors: theme.editorColors)) + .disabled(!supportsOverlay) + .accessibilityIdentifier("AdaEditor.BuildTargets.DiagnosticsOverlay") + + Text(supportsOverlay + ? L10n.text("Shows FPS and frame time the next time you run this target on desktop.") + : L10n.text("Available for local desktop runs.")) + .font(.system(size: 11)) + .foregroundColor(theme.editorColors.muted) + } + } + private var sceneList: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 2) { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift index 533c0b3c0..683505f21 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift @@ -33,6 +33,7 @@ struct EditorCodeFileView: View { var appearanceSettings: EditorAppearanceSettings = .shared @State private var caret = EditorCodeCaretState() + @State private var highlighting = EditorCodeHighlightState() @Environment(\.theme) private var theme var body: some View { @@ -438,12 +439,13 @@ extension EditorCodeFileView { size: fontSize ) if document.semanticTokens.isEmpty { - return EditorSyntaxHighlighter.spans( - for: document.content, + return highlighting.spans(for: EditorCodeHighlightState.Request( + documentID: document.id, + source: document.content, language: document.language, palette: colorPalette, keywordFont: keywordFont - ) + )) } return document.semanticTokens.map { token in diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorCodeHighlightState.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorCodeHighlightState.swift new file mode 100644 index 000000000..354e202e8 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorCodeHighlightState.swift @@ -0,0 +1,113 @@ +@_spi(AdaEngine) import AdaEngine +import Observation + +/// Syntax spans for one code view. +/// +/// Small sources are highlighted synchronously, as before. Larger sources are highlighted off the main +/// actor so a keystroke or newly opened tab does not wait for a whole-document tree-sitter pass; the view +/// keeps showing the most recent spans until the current text's spans are ready. Only one pass runs at a +/// time, and a burst of edits coalesces into a pass for the latest text. +@Observable +@MainActor +final class EditorCodeHighlightState { + struct Request: Equatable, Sendable { + /// Identifies the document whose earlier spans may stand in while its edits re-highlight. + var documentID: String + var source: String + var language: EditorSourceLanguage + var palette: EditorCodeColorPalette + var keywordFont: Font? + + /// Spans for `other` only approximate this request when they describe an earlier version of the + /// same document with the same styling; another document's colors must never be painted here. + func canReuseSpans(of other: Request) -> Bool { + documentID == other.documentID && language == other.language + && palette == other.palette && keywordFont == other.keywordFont + } + } + + /// Sources up to this size stay synchronous: highlighting them costs less than a frame. + static let synchronousUTF8Limit = 24 * 1_024 + + /// Bumped when background spans arrive so observing views redraw with them. + private(set) var revision = 0 + @ObservationIgnored private var displayed: (request: Request, spans: [TextEditorTokenSpan])? + @ObservationIgnored private var latest: Request? + @ObservationIgnored private var worker: Task? + @ObservationIgnored private(set) var backgroundPasses = 0 + + var isHighlighting: Bool { + worker != nil + } + + func spans(for request: Request) -> [TextEditorTokenSpan] { + _ = revision + if let displayed, displayed.request == request { + return displayed.spans + } + if request.source.utf8.count <= Self.synchronousUTF8Limit { + let spans = Self.highlight(request) + displayed = (request, spans) + latest = nil + return spans + } + schedule(request) + guard let displayed, request.canReuseSpans(of: displayed.request) else { + // A different document or style starts as plain text until its own pass is ready. + return [] + } + return displayed.spans + } + + /// Waits for scheduled background work; used by tests and callers that need settled spans. + func waitUntilIdle() async { + while let worker { + await worker.value + } + } + + private func schedule(_ request: Request) { + latest = request + guard worker == nil else { + return + } + worker = Task { [weak self] in + while let next = self?.pendingRequest() { + let spans = await Self.backgroundHighlight(next) + guard let self else { + return + } + // Publish even if newer text is pending: these spans are closer to it than the old ones. + self.displayed = (next, spans) + self.backgroundPasses += 1 + self.revision &+= 1 + } + self?.worker = nil + } + } + + private func pendingRequest() -> Request? { + guard let latest, displayed?.request != latest else { + self.latest = nil + return nil + } + return latest + } + + nonisolated private static func highlight(_ request: Request) -> [TextEditorTokenSpan] { + EditorSyntaxHighlighter.spans( + for: request.source, + language: request.language, + palette: request.palette, + keywordFont: request.keywordFont + ) + } + + @concurrent + nonisolated private static func backgroundHighlight(_ request: Request) async -> [TextEditorTokenSpan] { + let stall = PerformanceStallProbe.begin() + let spans = highlight(request) + PerformanceStallProbe.end("Studio.Highlight", since: stall, bytes: request.source.utf8.count, count: spans.count) + return spans + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift index ae9c8a6eb..2ed2ddc07 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift @@ -261,6 +261,7 @@ extension EditorViewModel { let projectSettings = projectURL.flatMap { try? ProjectSystem.loadProject(at: $0, fileManager: fileManager) } + let destination = selectedRunDestination.adaProjectDestination if let projectURL, let settings = projectSettings, settings.build.system == .adaScript, @@ -271,7 +272,11 @@ extension EditorViewModel { at: projectURL, statusTitle: "Build AdaScript Project" ) { [weak self] artifact in - self?.launchAdaScriptProject(artifact, projectName: projectName) + self?.launchAdaScriptProject( + artifact, + projectName: projectName, + diagnosticsOverlay: destination.supportsDiagnosticsOverlay && settings.run.diagnosticsOverlay + ) } return } @@ -300,7 +305,11 @@ extension EditorViewModel { runOnAdaPlayer() case .macOS, .windows, .linux: executeWorkspaceCommand( - .run(target: product, arguments: projectSettings?.run.arguments ?? []), + .run( + target: product, + arguments: projectSettings?.run.arguments ?? [], + diagnosticsOverlay: projectSettings?.run.diagnosticsOverlay ?? false + ), statusTitle: product.map { "Run \($0) on \(selectedRunDestination.rawValue)" } ?? "Run on \(selectedRunDestination.rawValue)" ) case .web: @@ -457,7 +466,8 @@ extension EditorViewModel { @discardableResult func launchAdaScriptProject( _ artifact: EditorAdaScriptProjectBuildArtifact, - projectName: String + projectName: String, + diagnosticsOverlay: Bool = false ) -> Bool { do { if let nativeAdaScriptLauncher { @@ -471,7 +481,11 @@ extension EditorViewModel { let width = Float(windowSettings.size.width) let height = Float(windowSettings.size.height) let controls = EditorGameWindowControls(projectSize: Size(width: width, height: height)) - let runtimeView = try EditorAdaScriptProjectRuntimeView(artifact: artifact, controls: controls) + let runtimeView = try EditorAdaScriptProjectRuntimeView( + artifact: artifact, + controls: controls, + diagnosticsOverlay: diagnosticsOverlay + ) let windowManager = try requireWindowManager() adaScriptRuntimeWindow?.close() let windowTitle = windowSettings.title.flatMap { $0.isEmpty ? nil : $0 } ?? projectName diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift index 5f0bc9f58..f72e203dd 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift @@ -9,6 +9,8 @@ extension EditorViewModel { } func openProjectItem(_ item: EditorProjectSidebarViewModel.Item, preparedDocument: EditorWorkbenchDocument?) { + let stall = PerformanceStallProbe.begin() + defer { PerformanceStallProbe.end("Studio.OpenFile", since: stall) } if item.isFolder { projectSidebar.select(item) projectSidebar.toggleFolder(item) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Workspace.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Workspace.swift index 7aa868166..501a76d37 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Workspace.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Workspace.swift @@ -58,6 +58,8 @@ extension EditorViewModel { } func refreshProjectFiles(logsRefresh: Bool = true) { + let stall = PerformanceStallProbe.begin() + defer { PerformanceStallProbe.end("Studio.ProjectTree.Refresh", since: stall, count: projectSidebar.items.count) } studioTools.scheduleReload() let selectedID = projectSidebar.selectedItem?.id let items = Self.projectTreeItems(for: project, fileManager: fileManager) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift index b413807b3..2cb030727 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift @@ -35,7 +35,9 @@ extension EditorWorkbenchViewModel { @concurrent private nonisolated static func decodeSceneDocument(_ content: String) async throws -> EditorSceneModel { try Task.checkCancellation() + let stall = PerformanceStallProbe.begin() let model = try EditorSceneModel.decode(from: content) + PerformanceStallProbe.end("Studio.SceneDecode", since: stall, bytes: content.utf8.count, count: model.entities.count) try Task.checkCancellation() return model } @@ -80,7 +82,11 @@ extension EditorWorkbenchViewModel { document.isDirty = true document.statusMessage = "Edited" } - self.refreshFileSearchSelection(documentID: documentID) + // A second document write per keystroke re-notifies every workbench observer; skip it + // unless find-in-file needs its match selection refreshed. + if self.textDocument(id: documentID)?.fileSearch.isPresented == true { + self.refreshFileSearchSelection(documentID: documentID) + } } ) } diff --git a/Editor/Tests/AdaEditorTests/EditorAgentMCPConnectionTests.swift b/Editor/Tests/AdaEditorTests/EditorAgentMCPConnectionTests.swift index f49610d4a..0732af279 100644 --- a/Editor/Tests/AdaEditorTests/EditorAgentMCPConnectionTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorAgentMCPConnectionTests.swift @@ -108,6 +108,7 @@ import Testing if supportsHTTP { #expect(server["type"] as? String == "http") #expect(server["url"] as? String == EditorMCPServerAddress.url) + #expect((server["headers"] as? [[String: String]])?.isEmpty == true) } else { #expect(server["type"] as? String == "stdio") #expect((server["command"] as? String)?.isEmpty == false) diff --git a/Editor/Tests/AdaEditorTests/EditorBuildTargetsTests.swift b/Editor/Tests/AdaEditorTests/EditorBuildTargetsTests.swift index c9e3ec614..45f1c65a8 100644 --- a/Editor/Tests/AdaEditorTests/EditorBuildTargetsTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorBuildTargetsTests.swift @@ -98,6 +98,7 @@ struct EditorBuildTargetsTests { toolchain: SwiftToolchain(swiftExecutablePath: "swift", sourceKitLSPExecutablePath: nil)) #expect(command.arguments == ["run", "-c", "release", "Game", "--", "--debug"]) #expect(command.environment["TARGET_ENV"] == "test") + #expect(command.environment["ADAENGINE_DIAGNOSTICS_OVERLAY"] == "0") } } diff --git a/Editor/Tests/AdaEditorTests/EditorCodeHighlightStateTests.swift b/Editor/Tests/AdaEditorTests/EditorCodeHighlightStateTests.swift new file mode 100644 index 000000000..305b8b667 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorCodeHighlightStateTests.swift @@ -0,0 +1,91 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@Suite("EditorCodeHighlightState") +@MainActor +struct EditorCodeHighlightStateTests { + private static func swiftSource(lines: Int, marker: String = "value") -> String { + (0.. EditorCodeHighlightState.Request { + EditorCodeHighlightState.Request(documentID: document, source: source, language: .swift, palette: .dark, keywordFont: nil) + } + + @Test("switching to another large document never shows the previous document's spans") + func documentSwitchStartsPlain() async { + let state = EditorCodeHighlightState() + let first = Self.swiftSource(lines: 1_500, marker: "first") + let second = Self.swiftSource(lines: 1_600, marker: "second") + _ = state.spans(for: Self.request(first, document: "text:First.swift")) + await state.waitUntilIdle() + #expect(!state.spans(for: Self.request(first, document: "text:First.swift")).isEmpty) + + // Another document, or the same one restyled, starts as plain text. + #expect(state.spans(for: Self.request(second, document: "text:Second.swift")).isEmpty) + var recolored = Self.request(first + "\n// edit", document: "text:First.swift") + recolored.palette = .monokai + #expect(state.spans(for: recolored).isEmpty) + await state.waitUntilIdle() + #expect(state.spans(for: recolored) == EditorSyntaxHighlighter.spans( + for: recolored.source, language: .swift, palette: .monokai + )) + } + + @Test("small sources keep synchronous spans") + func smallSourcesStaySynchronous() { + let source = Self.swiftSource(lines: 20) + #expect(source.utf8.count <= EditorCodeHighlightState.synchronousUTF8Limit) + let state = EditorCodeHighlightState() + + let spans = state.spans(for: Self.request(source)) + + #expect(!spans.isEmpty) + #expect(spans == EditorSyntaxHighlighter.spans(for: source, language: .swift, palette: .dark)) + #expect(!state.isHighlighting) + #expect(state.backgroundPasses == 0) + } + + @Test("large sources highlight off the main actor and keep the previous spans meanwhile") + func largeSourcesHighlightInBackground() async { + let original = Self.swiftSource(lines: 1_500) + #expect(original.utf8.count > EditorCodeHighlightState.synchronousUTF8Limit) + let state = EditorCodeHighlightState() + + #expect(state.spans(for: Self.request(original)).isEmpty) + #expect(state.isHighlighting) + await state.waitUntilIdle() + let expected = EditorSyntaxHighlighter.spans(for: original, language: .swift, palette: .dark) + #expect(!expected.isEmpty) + #expect(state.spans(for: Self.request(original)) == expected) + #expect(state.revision == 1) + + let edited = original + "\nlet appended = 1" + // The edit is shown with the previous colors until its own pass finishes. + #expect(state.spans(for: Self.request(edited)) == expected) + await state.waitUntilIdle() + #expect(state.spans(for: Self.request(edited)) == EditorSyntaxHighlighter.spans(for: edited, language: .swift, palette: .dark)) + #expect(state.backgroundPasses == 2) + } + + @Test("a burst of edits coalesces into a pass for the latest text") + func editBurstCoalesces() async { + let state = EditorCodeHighlightState() + let sources = (0..<6).map { Self.swiftSource(lines: 1_500, marker: "edit\($0)_") } + + for source in sources { + _ = state.spans(for: Self.request(source)) + } + await state.waitUntilIdle() + + // The worker starts after the synchronous burst, so the edits collapse into at most one pass + // beyond any pass that was already running; intermediate sources are never highlighted. + #expect((1...2).contains(state.backgroundPasses)) + let last = sources[sources.count - 1] + #expect(state.spans(for: Self.request(last)) == EditorSyntaxHighlighter.spans(for: last, language: .swift, palette: .dark)) + #expect(!state.isHighlighting) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorSceneYAMLPayloadDecoderTests.swift b/Editor/Tests/AdaEditorTests/EditorSceneYAMLPayloadDecoderTests.swift new file mode 100644 index 000000000..53b1460e5 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorSceneYAMLPayloadDecoderTests.swift @@ -0,0 +1,145 @@ +import AdaStudioCore +import Foundation +import Testing +import Yams + +@testable import AdaEditor + +@Suite("Scene YAML payload decoding") +struct EditorSceneYAMLPayloadDecoderTests { + /// Covers scalar resolution, nesting, anchors/aliases, merge keys and string styles. + private static let scene = #""" + format: ada.scene + schemaVersion: 1 + engineVersion: 1.0.0 + scene: + id: 123 + name: "СЕВЕР \xB7 north" + entities: + - id: root + name: Root + enabled: true + components: + AdaTransform.Transform: + position: [0, -1.5, 2e-3] + rotation: [0, 0, 0, 1] + scale: &unit [1, 1, 1] + - id: mesh + parent: root + name: 'Квадрат' + enabled: yes + components: + AdaTransform.Transform: + position: [1, 2, 3] + scale: *unit + AdaPhysics.PhysicsBody3DComponent: + mode: + static: {} + shapes: + - fixture: + triangleMesh: + _0: + vertices: + - x: 80 + z: 75 + y: -4.80000004172325e-1 + - {x: .inf, y: -.Inf, z: 0x1F} + indices: [0, 1, 2] + Custom.Values: + nothing: ~ + empty: + nullWord: null + truth: true + falsehood: False + hex: 0x1F + octal: 0o17 + huge: 18446744073709551615 + negative: -42 + float: 3.25 + exponent: 5e-4 + quotedNumber: "123" + singleQuoted: 'true' + date: 2026-11-08 + version: 1.0.0 + text: plain words + multiline: | + first + second + folded: > + folded + text + base: &base {a: 1, b: two} + merged: + <<: *base + b: three + other: &other {b: four, c: 5, d: [1]} + multiMerged: + <<: [*base, *other] + c: six + nested: [[1, [2, {k: v}]], {list: [a, b]}] + editor: + selectedEntity: mesh + expandedEntities: [root] + viewport: + position: [0, 0] + zoom: 1.5 + """# + + @Test("Direct payload conversion matches the YAMLDecoder path") + func matchesYAMLDecoder() throws { + let fast = try EditorSceneYAMLPayloadDecoder.decode(Self.scene) + let reference = try YAMLDecoder(encoding: .utf8).decode(EditorSceneModel.self, from: Self.scene) + + #expect(fast == reference) + #expect(try EditorSceneModel.decode(from: Self.scene) == reference) + let values = try #require(fast.entities.last?.components["Custom.Values"]) + #expect(values["huge"] == .uint(UInt64.max)) + #expect(values["quotedNumber"] == reference.entities.last?.components["Custom.Values"]?["quotedNumber"]) + #expect(fast.scene.id == "123") + #expect(values["merged"] == .object(["a": .int(1), "b": .string("three")])) + #expect(values["multiMerged"]?["c"] == .string("six")) + } + + @Test("Invalid documents fail the same way through the fallback path") + func invalidDocumentsStillFail() throws { + // Yams rejects duplicate keys while parsing, so neither path can disagree about them. + let duplicateComponents = Self.scene.replacingOccurrences( + of: " enabled: true\n components:\n", + with: " enabled: true\n components: {}\n components:\n" + ) + #expect(throws: (any Error).self) { try EditorSceneYAMLPayloadDecoder.decode(duplicateComponents) } + #expect(throws: (any Error).self) { try EditorSceneModel.decode(from: duplicateComponents) } + + let missingComponents = "format: ada.scene\nschemaVersion: 1\nscene: {id: s, name: S}\nentities:\n- {id: a, name: A, enabled: true}\n" + #expect(throws: EditorSceneYAMLPayloadDecoder.Unsupported.self) { try EditorSceneYAMLPayloadDecoder.decode(missingComponents) } + + // Yams hides these marker keys from keyed containers; the fallback path keeps that behavior. + let markerKey = Self.scene.replacingOccurrences(of: " truth: true\n", with: " truth: true\n yamlAnchor: hidden\n") + #expect(markerKey != Self.scene) + #expect(throws: EditorSceneYAMLPayloadDecoder.Unsupported.self) { try EditorSceneYAMLPayloadDecoder.decode(markerKey) } + #expect(try EditorSceneModel.decode(from: markerKey) == YAMLDecoder(encoding: .utf8).decode(EditorSceneModel.self, from: markerKey)) + #expect(throws: (any Error).self) { try EditorSceneModel.decode(from: missingComponents) } + } + + @Test("Profile decoding a real YAML scene", .enabled(if: ProcessInfo.processInfo.environment["ADAEDITOR_PERF_SCENE"] != nil)) + func realSceneDecode() throws { + let path = try #require(ProcessInfo.processInfo.environment["ADAEDITOR_PERF_SCENE"]) + let content = try String(contentsOfFile: path, encoding: .utf8) + let clock = ContinuousClock() + let fastStart = clock.now + let fast = try EditorSceneYAMLPayloadDecoder.decode(content) + let fastDuration = fastStart.duration(to: clock.now) + let parseStart = clock.now + let parser = try Parser(yaml: content, resolver: Resolver.basic.appending(.merge), encoding: .utf8) + _ = try withExtendedLifetime(parser) { try parser.singleRoot() } + let parseDuration = parseStart.duration(to: clock.now) + var line = "Scene decode benchmark: bytes=\(content.utf8.count), entities=\(fast.entities.count), payload path=\(fastDuration), of which Yams parse≈\(parseDuration)" + if ProcessInfo.processInfo.environment["ADAEDITOR_PERF_SCENE_COMPARE"] != nil { + let referenceStart = clock.now + let reference = try YAMLDecoder(encoding: .utf8).decode(EditorSceneModel.self, from: content) + line += ", YAMLDecoder path=\(referenceStart.duration(to: clock.now))" + #expect(fast == reference) + } + print(line) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorTextInteractionPerformanceTests.swift b/Editor/Tests/AdaEditorTests/EditorTextInteractionPerformanceTests.swift index 1c7431a91..72a67d418 100644 --- a/Editor/Tests/AdaEditorTests/EditorTextInteractionPerformanceTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorTextInteractionPerformanceTests.swift @@ -1,6 +1,7 @@ @_spi(AdaEngine) import AdaEngine @_spi(Internal) @testable import AdaInput @_spi(Internal) @testable import AdaUI +import Foundation import Math import Observation import Synchronization @@ -122,6 +123,53 @@ struct EditorTextInteractionPerformanceTests { } } + /// Opt-in CPU benchmark for opening and typing in a large Swift file (no GPU/presentation time). + @Test("Profile opening and typing in a large Swift file", .enabled(if: ProcessInfo.processInfo.environment["ADAEDITOR_PERF_CODE"] != nil)) + func largeSwiftFileTypingBenchmark() async throws { + prepareRenderer() + let source = (0..<6_000).map { index in + " func value\(index)(_ input: Int) -> String { return \"item \\(input + \(index))\" } // note \(index)" + }.joined(separator: "\n") + let model = EditorWorkbenchViewModel( + openDocuments: [.text(EditorTextDocument(id: "text", title: "Large.swift", relativePath: "Large.swift", language: .swift, content: source))], + activeDocumentID: "text" + ) + let clock = ContinuousClock() + func milliseconds(_ duration: Duration) -> Double { + Double(duration.components.seconds) * 1_000 + Double(duration.components.attoseconds) / 1e15 + } + + let openStart = clock.now + let container = UIContainerView(rootView: PerformanceCodeView(model: model).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 1_200, height: 800) + container.bounds.size = container.frame.size + container.layoutIfNeeded() + container.viewTree.rootNode.draw(with: UIGraphicsContext()) + let open = milliseconds(openStart.duration(to: clock.now)) + let node = try #require(editorNode(in: container.viewTree.rootNode)) + _ = try container.uiFocusNode(matching: .runtimeID(String(UInt(bitPattern: node.id), radix: 16))) + node.setSelection(to: node.offset(line: 3_000, column: 4, lines: node.lines())) + + var keystrokes: [Double] = [] + for character in "let typed = 1234567890 // abc" { + let start = clock.now + node.insertText(String(character)) + container.layoutIfNeeded() + container.viewTree.rootNode.draw(with: UIGraphicsContext()) + keystrokes.append(milliseconds(start.duration(to: clock.now))) + } + keystrokes.sort() + + // The previous path re-highlighted the whole edited document synchronously on every keystroke. + let edited = model.textDocument(id: "text")?.content ?? source + let highlightStart = clock.now + _ = EditorSyntaxHighlighter.spans(for: edited + " ", language: .swift, palette: model.codeColorPalette) + let highlight = milliseconds(highlightStart.duration(to: clock.now)) + + #expect(model.textDocument(id: "text")?.content.contains("let typed = 1234567890 // abc") == true) + print("Code editor benchmark: bytes=\(source.utf8.count), open+first draw=\(open) ms, keystroke p50=\(keystrokes[keystrokes.count / 2]) ms, p95=\(keystrokes[Int(Double(keystrokes.count) * 0.95)]) ms, max=\(keystrokes.last ?? 0) ms, synchronous full highlight (old per-keystroke cost)=\(highlight) ms") + } + private func makeModel() -> EditorWorkbenchViewModel { let source = "let value = \"Привет 👩🏽‍💻 e\u{301}\"\nlet other = 2\n" + String(repeating: "let item = 3\n", count: 30) return EditorWorkbenchViewModel( diff --git a/Editor/Tests/AdaEditorTests/EditorWindowsACPTests.swift b/Editor/Tests/AdaEditorTests/EditorWindowsACPTests.swift index 15322b33b..bcafb91a4 100644 --- a/Editor/Tests/AdaEditorTests/EditorWindowsACPTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorWindowsACPTests.swift @@ -201,6 +201,30 @@ struct EditorWindowsACPTests { #expect(received == arguments) } + @Test("Native npm launchers resolve directly to an executable with literal arguments") + func nativeNpmShim() throws { + let fixture = try Fixture(mode: "cancel") + defer { fixture.remove() } + let executable = fixture.root.appendingPathComponent("node_modules/opencode-ai/bin/opencode.exe") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: executable) + let shim = fixture.root.appendingPathComponent("opencode.cmd") + let launcher = #""%dp0%\node_modules\opencode-ai\bin\opencode.exe" %*"# + try ("@ECHO off\r\nSET dp0=%~dp0\r\n" + launcher + "\r\n").write(to: shim, atomically: true, encoding: .utf8) + let arguments = ["acp", "Игра & путь", "%PATH%"] + do { + let invocation = try EditorWindowsAgentCommand.resolve(shim.path, arguments: arguments, environment: [:]) + #expect(invocation.executable.path == executable.path) + #expect(invocation.arguments == arguments) + } catch { + Issue.record("Native npm launcher could not be resolved: \(error)") + } + try (launcher + " & echo unexpected").write(to: shim, atomically: true, encoding: .utf8) + #expect(throws: EditorAgentCatalogError.self) { + try EditorWindowsAgentCommand.resolve(shim.path, arguments: arguments, environment: [:]) + } + } + @Test("Windows archives reject drive paths, alternate streams, traversal and links") func archivePaths() throws { try EditorAgentCatalogService.validateArchive(names: "./agent.exe\n", details: "-rw-r--r-- agent.exe\n", command: "./agent.exe") diff --git a/script/ada_tools/cli.py b/script/ada_tools/cli.py index 5f4051560..99c36e459 100644 --- a/script/ada_tools/cli.py +++ b/script/ada_tools/cli.py @@ -39,14 +39,20 @@ def parser(): prepare.add_argument("components", nargs="*", type=component, metavar="COMPONENT", help="studio, swift, tint, wasm, playground") prepare.add_argument("--skip-system", action="store_true", help="Skip apt/brew/winget system prerequisites") - def build_options(command): - command.add_argument("--debug", action="store_true", help="Debug configuration (default: release)") + def build_options(command, profile=False): + if profile: + mode = command.add_mutually_exclusive_group() + mode.add_argument("--debug", action="store_true", help="Debug configuration (default: release)") + mode.add_argument("--profile", action="store_true", + help="Release optimizations plus debug symbols for CPU profilers (separate cache)") + else: + command.add_argument("--debug", action="store_true", help="Debug configuration (default: release)") command.add_argument("--scratch-path", type=Path, help="Isolated build cache (default: .build-tools/ada-tool//build/)") install = commands.add_parser("install", parents=[common], help="Build and install a complete standalone distribution, or copy --source") targets = install.add_subparsers(dest="target", required=True) app = targets.add_parser("studio", parents=[common], help="Install Studio for the current user") - build_options(app) + build_options(app, profile=True) app.add_argument("--source", type=Path, help="Complete extracted distribution or macOS .app; skips compilation") app.add_argument("--destination", type=Path, help="Exact installed application directory (.app on macOS)") app.add_argument("--launch", action="store_true") @@ -55,7 +61,7 @@ def build_options(command): targets = builder.add_subparsers(dest="target", required=True) for target in ("studio", "engine"): command = targets.add_parser(target, parents=[common]) - build_options(command) + build_options(command, profile=True) command = targets.add_parser("playground", parents=[common], help="Build the existing Studio DOM frontend") command.add_argument("--cloud-panels", type=Path, help="adawebsite dist-panels directory") command = targets.add_parser("wasm", parents=[common], help="Export release DOM Studio + AdaWebPlayer (WSL on Windows)") @@ -66,7 +72,9 @@ def build_options(command): command.add_argument("--studio-origin", action="append", default=None, help="Allowed Studio origin (repeatable; default: http://127.0.0.1:8080)") runner = commands.add_parser("run", parents=[common], help="Open the installed Studio") runner.add_argument("target", choices=("studio",)) - runner.add_argument("--debug", action="store_true") + variants = runner.add_mutually_exclusive_group() + variants.add_argument("--debug", action="store_true") + variants.add_argument("--profile", action="store_true", help="Open the profiling installation") runner.add_argument("--destination", type=Path) preview = commands.add_parser("serve", parents=[common], help="Serve built playground files on loopback; no Cloud backend") preview.add_argument("target", choices=("playground", "wasm")) @@ -95,7 +103,7 @@ def main(argv=None): result = studio.build(ctx, args) print(result) elif args.command == "run": - destination = args.destination or studio.default_destination(ctx, args.debug) + destination = args.destination or studio.default_destination(ctx, args.debug, args.profile) if not ctx.dry: studio.validate(destination, ctx.host) studio.launch(ctx, destination) diff --git a/script/test_ada_tool.py b/script/test_ada_tool.py index 45cc34137..a409a340a 100644 --- a/script/test_ada_tool.py +++ b/script/test_ada_tool.py @@ -172,6 +172,47 @@ def test_studio_build_plans_select_native_products_and_debug_configuration(self) self.assertIn("--configuration debug", plan) self.assertEqual(list(root.iterdir()), []) + def test_profile_build_keeps_release_optimizations_and_emits_symbols(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for host in ("windows", "macos", "linux"): + for target in ("studio", "engine"): + args = parser().parse_args(["build", target, "--profile", "--dry-run"]) + ctx = Context(args, root=root, host=host) + ctx.arch = "x86_64" + output = io.StringIO() + with contextlib.redirect_stderr(output): + studio.build(ctx, args) + plan = output.getvalue() + if host == "macos" and target == "studio": + self.assertIn("-configuration Release", plan) + self.assertIn("DEBUG_INFORMATION_FORMAT=dwarf-with-dsym", plan) + else: + self.assertIn("--configuration release", plan) + self.assertIn("-Xswiftc -g", plan) + if host == "windows" and target == "studio": + self.assertIn("-Xswiftc -debug-info-format=codeview", plan) + self.assertIn("-Xlinker -debug", plan) + # Profiling builds never reuse or invalidate the plain release cache. + self.assertIn(target + "-profile", plan) + self.assertEqual(list(root.iterdir()), []) + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + parser().parse_args(["build", "studio", "--profile", "--debug"]) + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + parser().parse_args(["run", "studio", "--profile", "--debug"]) + + def test_profile_installation_lives_beside_release_and_debug(self): + with tempfile.TemporaryDirectory() as directory: + for host in ("windows", "macos", "linux"): + ctx = Context(parser().parse_args(["run", "studio", "--dry-run"]), root=Path(directory), host=host) + names = [studio.default_destination(ctx, debug, profile).name + for debug, profile in ((False, False), (True, False), (False, True))] + self.assertEqual(len(set(names)), 3, names) + self.assertTrue("Profile" in names[2] or names[2] == "ada-studio-profile", names) + args = parser().parse_args(["install", "studio", "--profile", "--dry-run", "--no-shortcut"]) + self.assertTrue(args.profile) + self.assertFalse(args.debug) + def test_windows_wasm_plan_uses_worker_compiler_and_reuses_frontend(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) From d01a30df40a6d1b4250b1eb05da2fb7d027b4206 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Tue, 10 Nov 2026 08:13:45 +0300 Subject: [PATCH 4/4] Pipelined rendering, GPU shadow culling and path tracing denoising Pipelined rendering (Documentation/Designs/pipelined-rendering.md): - RenderPipelining lets a SceneView render world update one frame behind its simulation, with material snapshots taken at extraction. - The SceneView render-texture pool is sized for frames in flight, so frames overlap the GPU instead of being skipped. - ADAENGINE_GPU_TIMINGS=1 reports per-command-buffer GPU time on WebGPU. Shadows: - GPU-driven shadow batching (shadow caster registry, per-view GPU culling, multi-draw). - Meshlet cluster culling for shadow views. Runs of visible clusters are merged into single draws, because tens of thousands of draws per Dawn multi-draw stall the D3D12 queue. Path tracing (Documentation/Designs/path-tracing-denoising.md): - The first hit comes from the raster G-buffer. PathTracingGeometry3D separates raster-only detail from traced proxies. - History survives camera and light motion. - A portable SVGF-style denoiser, and DLSS Ray Reconstruction through the Dawn/NGX bridge, selected by PathTracing3D.denoiser. DLSS: - The NGX runtime DLLs are found next to the executable or in the SDK (ADAENGINE_DLSS_SDK_PATH). - Studio builds and packages stage nvngx_dlss.dll and nvngx_dlssd.dll. - A missing runtime is reported as such. Fixes: - The shader cache rolls back the source hash of a stage that failed to compile, instead of reusing a stale translation forever. Co-Authored-By: Claude Opus 5.5 --- Documentation/DLSS.md | 16 +- .../Designs/gpu-driven-shadow-batching.md | 240 +++++++ .../Designs/meshlet-shadow-culling.md | 251 +++++++ .../Designs/path-tracing-denoising.md | 263 +++++++ .../path-tracing-interactive-quality.md | 56 ++ Documentation/Designs/pipelined-rendering.md | 41 ++ Documentation/PathTracing.md | 74 +- .../SponzaFlyingLights-2026-11-08.md | 114 +++ .../2026-11-09-GPUDrivenShadowBatching.md | 31 + Documentation/RenderingFoundation.md | 25 + Documentation/Tooling.md | 68 ++ Package.resolved | 2 +- Sources/AdaApp/AppBuilder.swift | 31 +- Sources/AdaApp/RenderPipelining.swift | 80 +++ .../Assets/Shaders/flat3d.glsl | 6 + .../Assets/Shaders/path_tracing_3d.glsl | 267 +++++-- .../CorePipelines/Core3DPlugin.swift | 1 + .../CorePipelines/GPUShadowCull3D.swift | 196 +++++ .../CorePipelines/Lighting3D.swift | 1 + .../CorePipelines/LocalLighting3D.swift | 38 + .../CorePipelines/MeshVisibility3D.swift | 11 + .../CorePipelines/PBR3DUniforms.swift | 3 +- .../CorePipelines/PathTracing3D.swift | 25 +- .../CorePipelines/PathTracingGeometry3D.swift | 20 + .../PrepareEnvironment3DTexturesSystem.swift | 11 +- .../DirectionalShadow3DRenderNode.swift | 96 +-- .../LocalShadow3DRenderNode.swift | 148 +++- .../PathTracing3DRenderNode.swift | 156 +++- .../ScreenSpaceReflectionRenderNode.swift | 2 + .../RenderNodes3D/Temporal3DRenderNode.swift | 6 +- .../RenderPipelines3D/Flat3DPipeline.swift | 4 +- .../CorePipelines/ShadowBatchEncoder3D.swift | 176 +++++ .../ShadowCasterRegistry3D.swift | 671 ++++++++++++++++++ .../CorePipelines/Temporal3DHistory.swift | 1 + Sources/AdaECS/Scheduler.swift | 2 + Sources/AdaEngine/AdaEngine.docc/AdaEngine.md | 1 + .../Articles/Developers/PipelinedRendering.md | 55 ++ Sources/AdaEngine/SceneView/SceneView.swift | 18 +- .../SceneView/SceneViewCoordinator.swift | 41 +- .../AdaRender/Backends/CommandEncoder.swift | 27 + .../Headless/HeadlessRenderBackend.swift | 1 + .../Misc/MetalRenderCommandEncoder.swift | 16 + .../AdaRender/Backends/RenderBackend.swift | 22 + .../WebGPU/Misc/WGPUCommandEncoder.swift | 47 +- .../WebGPU/Misc/WGPUDLSSUpscaler.swift | 69 +- .../Misc/WGPURenderCommandEncoder.swift | 67 +- .../Misc/WGPUShadowClusterCullProcessor.swift | 280 ++++++++ .../WebGPU/Misc/WGPUShadowCullProcessor.swift | 224 ++++++ .../WebGPU/Misc/WGPUShadowCullShaders.swift | 57 ++ .../WebGPU/Misc/WGPUTimestampQuery.swift | 149 ++++ .../Backends/WebGPU/PipelineDiskCache.swift | 335 +++++++++ .../Backends/WebGPU/WebGPURenderBackend.swift | 110 ++- .../Backends/WebGPU/WebGPURenderDevice.swift | 33 +- Sources/AdaRender/Camera/CameraPlugin.swift | 10 + Sources/AdaRender/Extract.swift | 5 + Sources/AdaRender/Materials/PBRMaterial.swift | 145 +++- Sources/AdaRender/Misc/GPUShadowCull.swift | 249 +++++++ .../AdaRender/RenderGraph/RenderNode.swift | 5 +- Sources/AdaRender/RenderWorldPlugin.swift | 40 +- Sources/AdaRender/Shaders/ShaderCache.swift | 25 + .../ShaderCompiler/ShaderCompiler.swift | 10 + Sources/AdaRender/TemporalUpscaling.swift | 61 ++ .../AdaScene/3D/Model3DExtractionCache.swift | 138 +++- Sources/AdaScene/3D/Model3DPlugin.swift | 23 +- Sources/CWebGPUDLSS/DLSSDevice.cpp | 44 +- Sources/CWebGPUDLSS/DLSSInterop.h | 20 + Sources/CWebGPUDLSS/DLSSView.cpp | 154 +++- Sources/CWebGPUDLSS/include/AdaWebGPUDLSS.h | 10 + .../GPUShadowBatchingSceneTests.swift | 156 ++++ .../AdaEngineTests/LocalLighting3DTests.swift | 44 ++ .../Model3DIncrementalExtractionTests.swift | 37 + .../PipelinedRenderingTests.swift | 96 +++ .../Render3DPathTracingTests.swift | 123 +++- .../Render3DSponzaBenchmarkTests.swift | 58 +- Tests/AdaEngineTests/SceneViewTests.swift | 2 +- .../ShadowCasterRegistry3DTests.swift | 239 +++++++ .../TerrainInstancingPaletteTests.swift | 1 + .../GPUShadowCullInputTests.swift | 61 ++ .../PipelineDiskCacheTests.swift | 233 ++++++ .../PipelinedRenderWorldTests.swift | 56 ++ .../WGPUBindingStateTests.swift | 71 ++ .../WGPUShadowCullProcessorTests.swift | 109 +++ script/ada_tools/studio.py | 53 +- script/build_dlss.py | 8 +- script/package_studio_windows.py | 8 + script/webgpu_validation_package.py | 2 + 86 files changed, 6682 insertions(+), 299 deletions(-) create mode 100644 Documentation/Designs/gpu-driven-shadow-batching.md create mode 100644 Documentation/Designs/meshlet-shadow-culling.md create mode 100644 Documentation/Designs/path-tracing-denoising.md create mode 100644 Documentation/Designs/path-tracing-interactive-quality.md create mode 100644 Documentation/Performance/SponzaFlyingLights-2026-11-08.md create mode 100644 Documentation/Plans/2026-11-09-GPUDrivenShadowBatching.md create mode 100644 Sources/AdaApp/RenderPipelining.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/GPUShadowCull3D.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/PathTracingGeometry3D.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/ShadowBatchEncoder3D.swift create mode 100644 Sources/AdaCorePipelines/CorePipelines/ShadowCasterRegistry3D.swift create mode 100644 Sources/AdaEngine/AdaEngine.docc/Articles/Developers/PipelinedRendering.md create mode 100644 Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowClusterCullProcessor.swift create mode 100644 Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullProcessor.swift create mode 100644 Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullShaders.swift create mode 100644 Sources/AdaRender/Backends/WebGPU/Misc/WGPUTimestampQuery.swift create mode 100644 Sources/AdaRender/Backends/WebGPU/PipelineDiskCache.swift create mode 100644 Sources/AdaRender/Misc/GPUShadowCull.swift create mode 100644 Tests/AdaEngineTests/GPUShadowBatchingSceneTests.swift create mode 100644 Tests/AdaEngineTests/PipelinedRenderingTests.swift create mode 100644 Tests/AdaEngineTests/ShadowCasterRegistry3DTests.swift create mode 100644 Tests/AdaRenderTests/GPUShadowCullInputTests.swift create mode 100644 Tests/AdaRenderTests/PipelineDiskCacheTests.swift create mode 100644 Tests/AdaRenderTests/PipelinedRenderWorldTests.swift create mode 100644 Tests/AdaRenderTests/WGPUShadowCullProcessorTests.swift diff --git a/Documentation/DLSS.md b/Documentation/DLSS.md index a2b9d6740..bcb4abd36 100644 --- a/Documentation/DLSS.md +++ b/Documentation/DLSS.md @@ -28,7 +28,21 @@ Stage the signed runtime and license beside each executable: python script/build_dlss.py --stage-runtime path/to/bin ``` -`nvngx_dlss.dll` and `NVIDIA-DLSS-LICENSE.txt` come from the pinned SDK. +`nvngx_dlss.dll` (Super Resolution), `nvngx_dlssd.dll` (Ray Reconstruction) and +`NVIDIA-DLSS-LICENSE.txt` come from the pinned SDK. `ada_tool build/install studio` and the +Windows Studio packager copy them automatically when the bridge is prepared. For `swift run` of an +example, either stage them beside the executable or set `ADAENGINE_DLSS_SDK_PATH` to the prepared SDK +(`.build-tools/dlss-sdk`); NGX then loads them from there. Without either, DLSS reports +"DLSS runtime nvngx_dlss.dll not found next to …" rather than an adapter or driver problem. + +## Ray Reconstruction + +Path-traced views use DLSS Ray Reconstruction (NGX "DLSS-D") when `PathTracing3D.denoiser` is +`.automatic` or `.rayReconstruction`, the camera uses DLSS temporal upscaling, NGX reports the feature +on the adapter, and the tracer runs at input size. The bridge copies three RGBA16F guides (diffuse albedo, +specular albedo, world normal with packed roughness) into shared textures beside colour, depth and motion, +and evaluates with the frame's world-to-view and view-to-clip matrices. A failed evaluation disables Ray +Reconstruction for that view and falls back to Super Resolution of the noisy frame. The custom-engine project GUID is a development identity, not an NVIDIA-assigned application ID. diff --git a/Documentation/Designs/gpu-driven-shadow-batching.md b/Documentation/Designs/gpu-driven-shadow-batching.md new file mode 100644 index 000000000..2fc57f548 --- /dev/null +++ b/Documentation/Designs/gpu-driven-shadow-batching.md @@ -0,0 +1,240 @@ +# GPU-Driven Shadow Batching + +Status: Implemented on WebGPU (native and browser); native Metal and headless keep the CPU path. +Approval: explicit user approval in this Claude Code conversation on 2026-11-09 ("Ok, lets do that!"). +Revision 2 (2026-11-09): updated to the implementation after review; the human accepted the deviations +listed in §10 ("Accept, update design"). +Date: 2026-11-09. +Classification: improvement of an existing subsystem (shadow rendering, ADR-0018 / ADR-0020). +Evidence: [Sponza flying-lights measurements](../Performance/SponzaFlyingLights-2026-11-08.md); +Bevy 0.20-dev (`bevy_pbr/src/render/light.rs`, `bevy_light/src/lib.rs`, `mesh_preprocess.wesl`). +Existing contracts: [ADR-0018 local lights and shadow scheduling](../ArchitectureDecisions/0018-local-lights-and-shadow-scheduling.md), +[ADR-0020 GPU visibility](../ArchitectureDecisions/0020-gpu-visibility-and-animation-lod.md). +Plan: [2026-11-09-GPUDrivenShadowBatching.md](../Plans/2026-11-09-GPUDrivenShadowBatching.md). + +## 1. Problem + +Moving shadow-casting lights are CPU-bound. In the Sponza atrium (720p, WebGPU/D3D12, RTX 4070), +four moving point lights cost 12.8 ms per frame against 7.6 ms with no moving lights. Lowering the +resolution to 320×180 does not make either case faster. + +A moving light changes its six face matrices every frame, so all 24 faces are rebuilt and redrawn: + +| Per-frame CPU work (4 lights, after `ShadowBatchEncoder3D`) | Cost | +| --- | ---: | +| `VisibleMesh3DLists.prepare`: 24 lists, each rebuilt from every source, plus instance upload | ~1.6 ms | +| Recording ~2,330 shadow draws (~97 per face) | ~3.5 ms | + +The GPU has spare capacity. The cost scales with *faces × draws per face* on the CPU. + +## 2. Decision + +Move the per-face work for ordinary static casters to the GPU, as Bevy does: + +1. **Registry, kept between frames:** static casters are registered once; uploads happen only on change. +2. **One compute pass culls for every shadow view** and writes the indirect draw arguments. +3. **One multi-draw per draw group and view,** with no per-mesh CPU work, where the adapter supports it. + +The image must stay exactly the same. Casters that the GPU path cannot reproduce exactly stay on +today's CPU path (§4.6). + +Settled during Enrich (2026-11-09): + +| ID | Decision | +| --- | --- | +| E1 | Covers point faces, spot maps and directional cascades, which already share `ShadowBatchEncoder3D`. | +| E2 | Shadow geometry goes into **shadow-only shared buffers**. Mesh storage for other passes is unchanged. | +| E3 | Without multi-draw support: **one `drawIndexedIndirect` per mesh part**, same GPU culling, same image. | +| E4 | Target: shadows near-free on the CPU (§7). Reaching 144 fps overall also needs the separate main-pass work. | + +## 3. Goals and non-goals + +| ID | Goal | +| --- | --- | +| G1 | Identical shadow maps and final frames compared with the CPU-culled path. | +| G2 | CPU cost of shadow faces does not grow with the number of static casters (with multi-draw). | +| G3 | Caster changes (add, remove, move, material, visibility, `castShadows`) show up in the same frame. | +| G4 | Correct on every backend. The GPU path runs on WebGPU (native D3D12/Vulkan/Metal through Dawn, and the browser); native Metal and headless use the CPU path with the same image (§10). | + +Non-goals: +- Time-sliced or lower-rate shadow updates: these lag visually and are rejected. +- Main-pass batching. +- Moving all mesh storage into shared buffers. +- GPU LOD selection. +- Shadow occlusion culling with Hi-Z. +- GPU culling on native Metal in this change (§10). + +## 4. Design + +### 4.1 Caster registry (`ShadowCasterRegistry3D`, AdaCorePipelines) + +A render-world resource that persists between frames. A caster is one instance of one mesh part. + +- **Per caster:** world bounds (or "always visible"), its pooled part and an instance record + identical to the CPU path's shadow record (model matrix, base colour × instance tint, zero + material/texture/shadow flags). +- **Update rules:** + - The first shadow node of a frame rebuilds the caster arrays from the extracted sources; the second + node of the same frame reuses them (detected through the material frame counter). + - The arrays are compared with the previous ones. Only a change increments `revision` and uploads + the input; it also records the old and new bounds of every changed caster (`changedBounds`), or + `nil` when the change cannot be bounded (pool rebuild). +- **Cost:** a frame with nothing changed costs one array build and compare per caster, independent of + the number of faces and lights; nothing is uploaded. +- **Availability:** the registry builds nothing on devices without GPU shadow culling + (`RenderDevice.supportsGPUShadowCull`) or with `ADAENGINE_SHADOW_GPU=0`; it then releases its pools. + +### 4.2 Shadow geometry pool (inside the registry) + +Every eligible mesh part's original vertex and index bytes are copied on the GPU (`copyBufferToBuffer`) +into one shared vertex/index buffer pair **per vertex layout**, addressed with `baseVertex` / `firstIndex`. +Shadow pipelines are already specialised per vertex layout, so the CPU path's pipeline is reused and the +shader inputs are byte-identical. Meshes always use 32-bit indices; a part without them keeps the CPU path. + +- **Lifetime:** parts are keyed by their source buffers and kept alive by the pool. New parts are + appended. A full pool, or a pool with more than 4 MiB of parts unused for 300 frames that make up + over half of it, is rebuilt into new buffers by copying its live parts again; frames in flight keep + reading the old buffers. Freed ranges are not reused in place. +- **Memory:** the source geometry size, plus up to 50% headroom (at least 64 KiB per buffer). + `ShadowCasterRegistry3D.pooledBytes` exposes the size; it is not yet published in + `Render3DPerformanceMetrics` (§10). +- **Limit:** a layout whose shadow geometry would exceed 256 MiB (baseline WebGPU `maxBufferSize`) + sends every caster to the CPU path. +- **Copies** are encoded at the start of any shadow pass, so they never accumulate. + +### 4.3 GPU culling (`GPUShadowCullProcessor`) + +Each frame one dispatch covers (shadow view × caster) for every view that redraws: dirty point/spot +faces and all cascades. Faces kept by the existing face reuse are not culled. For each pair the pass: + +1. **Tests the caster against the view** with the same planes and bounds as `MeshVisibilityFrustum3D`, + with a small relative tolerance toward drawing, so it never rejects a caster the CPU test keeps. An + extra draw of an off-face caster is clipped or discarded by the atlas tile bounds and changes no pixel. +2. **Copies the surviving instance record** (the first 128 bytes the shadow shaders read) into a compacted + per-(view, part) segment with an atomic counter, the scheme `WGPUVisibilityProcessor` already uses. + The CPU writes each (view, part) `IndexedIndirectArguments` with instance count 0 beforehand. + +The processor is created by the render device, like the existing visibility processors. The WebGPU +implementation is WGSL in `AdaRender` (`WGPUShadowCullProcessor`). Native Metal and headless return no +processor (§10). + +### 4.4 Drawing + +The shadow shaders are unchanged: instances are read from the compacted buffer at vertex slot 3. + +Opaque casters do not depend on their material except for **single- vs double-sided** and the alpha +mode, which the fragment shader reads from the material uniform. Each opaque draw group therefore binds +the material buffer of one opaque member with the same sidedness. Draw groups are: + +- **Opaque:** one group per vertex layout and sidedness. +- **Alpha-masked (non-graph):** one group per vertex layout, material buffer and base-colour texture. + +Per shadow view, after the CPU-path batches, the encoder sets each group's pipeline, resources and pool +buffers, then: +- **With multi-draw** (`multi-draw-indirect` and `indirect-first-instance` on the adapter): one + `multiDrawIndexedIndirect` over the group's consecutive arguments; each argument carries its segment's + `firstInstance`. +- **Without it:** one `drawIndexedIndirect` per part, with `firstInstance` 0 and the instance buffer bound + at the segment's offset (decision E3). A zero instance count is a GPU no-op. + +Draw order does not affect the result: the shadow passes use a strict depth test and write the +fragment depth, the property `ShadowBatchEncoder3D` already relies on. + +### 4.5 Backend additions (AdaRender) + +- `RenderCommandEncoder.multiDrawIndexedIndirect(arguments:offset:drawCount:) -> Bool`; `false` when + unsupported (default, Metal, headless, browser). +- `RenderDevice.supportsMultiDrawIndirect`, `supportsGPUShadowCull`, `createGPUShadowCullProcessor()`. +- WebGPU requests `multiDrawIndirect` and `indirectFirstInstance` when the native adapter has them. +- `ADAENGINE_SHADOW_GPU=0` forces the CPU path; `ADAENGINE_SHADOW_MULTIDRAW=0` forces the E3 fallback. + +### 4.6 Casters that stay on the CPU path + +These keep the current `VisibleMesh3DLists` + `ShadowBatchEncoder3D` path, because their per-view +result depends on CPU state the GPU path would not reproduce exactly: + +| Caster | Reason | +| --- | --- | +| Skinned or morphed | Per-frame palettes and morph weights; small counts in practice. | +| Shader-graph alpha-masked | Needs the graph's own pipeline per material. | +| Has LOD alternatives | LOD selection uses per-view hysteresis on the CPU. | +| Finite `maximumDistance` | The distance fade drives the dither pattern per view. | +| Part without 32-bit indices, or with an unaligned vertex buffer | Cannot be pooled by a buffer copy. | +| Blend materials | Do not cast shadows; unchanged. | + +`VisibleMesh3DLists` only sees the CPU-path sources, so the per-face rebuild shrinks to them. Both paths +draw into the same face in one render pass. If GPU culling cannot be encoded for a frame, the dirty +faces are rebuilt from every caster on the CPU path instead. + +### 4.7 Face reuse + +A point/spot face kept from an earlier frame stays kept only if its matrix and CPU-path content are +unchanged and no GPU-path caster changed inside it: the registry's `changedBounds` (old and new bounds) +are tested against the face frustum. An unbounded change, a switch between paths, or a missed revision +redraws every face. + +### 4.8 Ownership + +| Module | Changes | +| --- | --- | +| AdaRender | Multi-draw encoder method, capabilities, cull processor protocol and WebGPU implementation, WebGPU feature request, index buffers usable as copy sources. | +| AdaCorePipelines | Registry and pools, `GPUShadowCull3D` helper, changes to `LocalShadow3DRenderNode` / `DirectionalShadow3DRenderNode`. | +| AdaScene | None. | + +## 5. Alternatives considered + +| Alternative | Why not chosen | +| --- | --- | +| Record draws once into render bundles and replay them per face | Removes recording cost but not culling; a bundle is invalid whenever a caster changes; it helps no other pass; Bevy does not use it. | +| Shared buffers for all passes (full Bevy mesh allocator) | Also speeds up the main pass, but changes import, every pass and memory ownership; decided against in E2. | +| One converted shadow vertex layout for all parts | Needs new shader variants and CPU-side conversion; per-layout byte copies reuse existing pipelines with identical inputs. | +| Draw every cube face in one pass with instancing ×6 | Needs clip-distance support (optional in WebGPU) and an atlas change; does not remove culling. | +| Time-sliced moving-light shadows | Visual lag; rejected by the requirement of no visual downgrade. | + +## 6. Risks + +- **Exactness:** the GPU frustum test must never reject a caster the CPU path draws; tested against the + CPU test with randomized views and bounds (A2). +- **Driver support:** without multi-draw the CPU gain is small (§9); the fallback keeps the image. +- **Memory:** a second copy of static shadow geometry with headroom; layouts above 256 MiB use the CPU path. + +## 7. Acceptance criteria + +| ID | Criterion | Result | +| --- | --- | --- | +| A1 | Full WebGPU validation gives the same issue list and differing-pixel counts as before, with the GPU path on, multi-draw forced off, and the GPU path off. | Met (348 tests, identical 25 pre-existing issues in all three modes). Metal goldens: blocked, no Mac. | +| A2 | Per-view GPU cull results include every caster the CPU frustum test keeps. | Met (`shadowCullKeepsEveryCasterTheCPUFrustumTestKeeps`). | +| A3 | Frames are bit-identical between CPU and GPU paths for opaque, single/double-sided, masked, instanced and mixed CPU-path casters, culled and reused faces. | Met (`GPUShadowBatchingSceneTests`, multi-draw on and off). | +| A4 | Add, remove, move, material change and `castShadows` are reflected; pool growth and rebuild keep parts addressable. | Met (`ShadowCasterRegistry3DTests`). | +| A5 | Sponza atrium, 720p, release, WebGPU/D3D12, RTX 4070: (4 moving lights) − (no moving lights) ≤ 1.0 ms, from 5.2 ms. | ~1.1 ms (8.42 vs 7.34 ms, 2 rounds, multi-draw active); accepted (§10). | +| A6 | `check_contribution.py` passes; Metal build and goldens on macOS. | Blocked: no Mac; full Editor rebuild not run while GitHub artifact downloads failed. | + +## 8. User documentation + +Internal performance improvement with no new public API. User-facing effect and switches: + +- `Documentation/RenderingFoundation.md`, **Shadow casting cost**: GPU culling, CPU-path casters, + multi-draw dependence, backends, memory and limit, switches. +- `Documentation/Tooling.md`, **GPU-driven shadows**: the two environment variables. + +## 9. Measured result + +| atrium, 720p, Hi-Z off | ms/frame | +| --- | ---: | +| No flying lights | 7.34 | +| 4 flying lights, GPU path + multi-draw | 8.42 | +| 4 flying lights, GPU culling, one indirect draw per mesh | 14.07 | +| 4 flying lights, GPU path off | 14.57 | + +## 10. Accepted deviations from revision 1 + +Accepted by the human on 2026-11-09: + +- Native Metal gets no GPU culling processor; it keeps the CPU path (same image, no speedup). +- The compute shader is WGSL in `AdaRender` instead of shared GLSL in `AdaCorePipelines`. +- The pool copies original bytes per vertex layout instead of one fixed shadow layout, and reclaims + space only by rebuilding. +- Opaque sidedness is handled by per-sidedness draw groups instead of a per-instance flag. +- Pool size is not yet reported in `Render3DPerformanceMetrics`. +- A layout over 256 MiB sends every caster to the CPU path. +- A5 measured ~1.1 ms against the 1.0 ms target; without multi-draw the gain is small. diff --git a/Documentation/Designs/meshlet-shadow-culling.md b/Documentation/Designs/meshlet-shadow-culling.md new file mode 100644 index 000000000..fafbf5636 --- /dev/null +++ b/Documentation/Designs/meshlet-shadow-culling.md @@ -0,0 +1,251 @@ +# Meshlet Culling for Shadow Views + +Status: Approved — implementation in progress. +Approval: explicit user approval in this Claude Code conversation on 2026-11-10 ("Approve, build it"). Shadow LOD (§6) is not approved and stays out of scope. +Date: 2026-11-09. +Classification: improvement of existing subsystems (shadow rendering and meshlets). +Extends: [GPU-driven shadow batching](gpu-driven-shadow-batching.md), +[ADR-0028 meshlets and portable cluster culling](../ArchitectureDecisions/0028-meshlets-and-portable-cluster-culling.md) +(which lists "independent meshlet culling of shadows" as later work), +[ADR-0018 local lights and shadow scheduling](../ArchitectureDecisions/0018-local-lights-and-shadow-scheduling.md). +Related: [pipelined rendering](pipelined-rendering.md), whose §8 render-path cuts this continues. + +## 1. Problem + +The Sponza demo should hold 140+ FPS (≤ 7.0 ms per frame) on an RTX 4070 (WebGPU/Dawn D3D12). +Views that face the scene run at 85–105 FPS and are GPU-bound: per-command-buffer timestamps +(`ADAENGINE_GPU_TIMINGS=1`, added during this investigation) show the queue busy 900–960 ms per second. + +| GPU work per frame, heavy view, 1890×784 | ms | +| --- | ---: | +| Local shadows (moving point light, ~5.5 faces redrawn, ~39 draws) | 4.3 | +| Main pass (meshlet culling and LOD) | 3.0 | +| Sun shadow (3 cascades) | 2.3 | +| UI, upscale, SSR composite | 0.1 | +| **Total** | **9.7** | + +The scene has 8.8 M triangles. One ivy part holds 4.9 M of them, and the 75 parts above 20 K +triangles hold 92%. The main pass culls 96-triangle clusters per view and selects meshlet LOD. +Shadow views do not: they cull whole parts (on the CPU, or per caster on the GPU), and a large part +that touches a face or cascade draws all its triangles. The ivy spans the atrium, so it is drawn whole +in nearly every shadow view. + +## 2. Decision + +Cull **meshlet clusters** for shadow views on the GPU, and draw only the surviving clusters. + +1. Large static meshlet-ready parts (§4.1) leave the per-caster shadow path and become **cluster casters**. +2. One compute dispatch per frame tests every cluster of every cluster caster against every shadow + view that redraws this frame (dirty point/spot faces and all cascades). +3. Survivors are written as **one indexed-indirect argument per cluster**, compacted per (view, part) + with a GPU counter. No index data is copied. +4. Each (view, part) issues one `multiDrawIndexedIndirect` that reads its draw count from the GPU + counter buffer. + +Culling alone does not change the image (§4.5). Shadow LOD is a separate, optional stage (§6, decision L1). + +## 3. Goals and non-goals + +| ID | Goal | +| --- | --- | +| G1 | Shadow maps and final frames are identical to today's path with culling only. | +| G2 | Shadow GPU cost scales with the clusters inside each view, not with whole parts. | +| G3 | CPU cost per frame stays bounded: one dispatch plus one draw call per (view, cluster caster). | +| G4 | Every backend stays correct; devices without the required features keep today's path. | +| G5 | Caster changes (add, remove, move, visibility, `castShadows`, asset reload) appear in the same frame. | + +Non-goals: + +- Time-sliced or lower-rate shadow updates (rejected in the batching design). +- Hi-Z occlusion or cone culling of shadow clusters. +- Meshlet culling for skinned, morphed, alpha-masked, Shader Graph or instanced casters. +- Changes to the main pass, to meshlet preparation or to the sidecar format. +- Native Metal, headless and browser WebGPU (they keep today's path; §8). + +## 4. Design + +### 4.1 Cluster casters (`ShadowCasterRegistry3D`) + +A part becomes a cluster caster when: + +- it passes `Meshlet3DEligibility.fallbackReason(_:allowInstances: false)` (singleton batch, static, + opaque standard PBR, no deformation or parallax), so the cluster bounds describe its geometry exactly; +- it has base meshlets (`part.meshlets`), whose clusters are contiguous ranges of the part's own index + buffer; +- it has at least `clusterCasterMinimumClusters` clusters (initially 256, about 24 K triangles; chosen by + measurement in the plan). Smaller parts cost more as separate draws than they save; +- the device supports the path (§4.4). + +The registry already rebuilds and compares its caster arrays once per frame, and only uploads when +something changes. It now also keeps: + +- a persistent **cluster table** for all cluster casters: world-space bounds per cluster (transformed + once when the caster changes), `firstIndex`, `indexCount`, and the caster's instance-record index; +- a part table: cluster range, vertex/index buffers, vertex layout, sidedness and material buffer. + +A cluster caster is removed from the per-caster lists, so it is never drawn twice. When any input +cannot be prepared (budget, missing meshlets), the part stays a per-caster caster. That is today's +behavior. + +### 4.2 Culling (`GPUShadowClusterCullProcessor`, AdaRender) + +One compute pass, encoded on the shadow node's command buffer before its render pass: + +- **Input:** the cluster table, the views redrawn this frame (6 planes each, the same + `MeshVisibilityFrustum3D` planes and tolerance as `WGPUShadowCullShaders`), and a per-(view, part) + segment table. +- **Dispatch:** x covers clusters (64 per workgroup), y covers views. +- **Per (view, cluster):** test the cluster bounds against the view planes. If the cluster intersects, + `atomicAdd` the (view, part) counter and write `IndexedIndirectArguments(indexCount, 1, firstIndex, 0, + instanceRecord)` into that segment. +- Counters are cleared with `clearBuffer` before the dispatch. No result is read back to the CPU. + +Output buffers live in a ring of `maxFramesInFlight` slots, released on command completion like +`WGPUMeshletProcessor` frames. The segment capacity for a (view, part) is the part's cluster count. +A frame needing more than **64 MiB** of arguments (about 3.3 M cluster-view pairs) falls back to the +per-caster path for that frame. Sponza needs about 15 MiB: about 92 K clusters × about 8.5 redrawn views × 20 B. + +### 4.3 Drawing + +After the existing per-caster draws of a view, the shadow encoder draws each cluster caster: + +1. Set the shadow pipeline for the part's vertex layout and sidedness. This is the same pipeline that + draws the part today, so the shaders are unchanged. +2. Bind the part's own vertex and index buffers, the registry instance buffer and the material buffer, + as the per-caster path does. +3. Call `multiDrawIndexedIndirect(arguments:offset:maxDrawCount:drawCountBuffer:drawCountOffset:)` over the + (view, part) segment. + +`RenderCommandEncoder` gains this count-buffer overload. It returns `false` when unsupported, as the +existing `multiDrawIndexedIndirect` does. + +Draw order does not matter: shadow passes use a strict depth test and write fragment depth, +which the batching design already relies on. + +### 4.4 Availability and switches + +- Requires native WebGPU with `multiDrawIndirect` and `indirectFirstInstance`, which the device already + requests when available, plus the existing GPU shadow culling. `RenderDevice.supportsGPUShadowClusterCull` + reports it. +- `ADAENGINE_SHADOW_MESHLETS=0` disables cluster casters. `ADAENGINE_SHADOW_GPU=0` disables them together + with the rest of the GPU shadow path. +- Point/spot face reuse is unchanged: faces kept by reuse are not culled or drawn. + +### 4.5 Why the image does not change + +- A culled cluster lies entirely outside one plane of the view frustum. +- Shadow pipelines clip depth (`unclippedDepth: false`), so such a cluster cannot produce a fragment. +- The test uses the existing planes with a tolerance toward drawing, so it never rejects a cluster + whose triangles reach the view. +- Surviving clusters draw the same triangles, in the same pipeline, with the same instance record. + +The rasterized depth is therefore identical. The tests compare shadow maps bit for bit (§9). + +## 5. Alternatives considered + +| Alternative | Why not | +| --- | --- | +| Reuse `WGPUMeshletProcessor` per shadow view | It copies surviving indices per view. The ivy alone needs up to 59 MB per view and frame slot, and shadows add up to 18 views. | +| Per-cluster arguments with zero instance count, no compaction | No count buffer needed, but every culled cluster still costs a GPU command-processor slot: about 92 K × views per frame. | +| Shadow proxy meshes (simplified casters) | Fastest, but it changes shadow shapes (coarse ivy) and needs a new shadow-only mesh API. It stays an option if this falls short. | +| Time-slicing moving-light faces | Rejected by the batching design: visible lag. | +| Lower shadow resolution | Does not help: the cost is triangles, not pixels. | + +## 6. Shadow LOD (decision L1, for the human) + +Clusters can also pick a meshlet LOD per shadow view, from the shadow tile resolution and a pixel error, +as the main pass does. That reduces ivy triangles much further, but **the image is no longer identical**: +simplified casters move shadow edges by up to the error and can add or remove small self-shadowing. + +Proposal: **ship culling first (this design), measure, then decide.** If heavy views still miss 7.0 ms, +a follow-up adds `ShadowSettings.meshletLODPixelError` (default 0, meaning off), uses LOD hierarchies +with index-usable uploads, and gets its own measurement and visual comparison. That follow-up needs +separate approval. + +## 7. Platform impact + +| Platform | Behavior | +| --- | --- | +| Windows/Linux/macOS through Dawn (native WebGPU) with multi-draw | Cluster culling | +| Native WebGPU without multi-draw | Today's per-caster GPU path | +| Native Metal, headless | Today's CPU path | +| Browser WebGPU | Today's path (no count-buffer multi-draw) | + +## 8. Ownership + +- `AdaRender`: cluster cull processor protocol and WebGPU implementation (WGSL), the count-buffer + multi-draw overload, `supportsGPUShadowClusterCull`. +- `AdaCorePipelines`: cluster casters in `ShadowCasterRegistry3D`, the dispatch and draw in + `GPUShadowCull3D` / `ShadowBatchEncoder3D`, used by the directional and local shadow nodes. +- No public API changes besides the new optional `RenderCommandEncoder` overload and the device flag. + +## 9. Validation and acceptance + +Tests (Swift Testing; the GPU ones gated like the existing WebGPU GPU suites): + +- Eligibility: what becomes a cluster caster, and what falls back and why. +- Cluster table update and revision behavior for G5 (add, move, hide, `castShadows`, reload). +- GPU: for a test scene with a large part crossing several faces and cascades, the shadow atlas with + cluster culling equals the atlas with `ADAENGINE_SHADOW_MESHLETS=0`, **byte for byte**. +- Fallback: argument budget exceeded, or device without support → today's path, same image. +- Existing golden frames (`Render3DGPUTests`, WebGPU references) unchanged; `script/validate_webgpu.sh`. + +Acceptance in the Sponza demo (autopilot, 1890×784, default lighting, RTX 4070), measured with +`ADAENGINE_GPU_TIMINGS=1`: + +| ID | Criterion | +| --- | --- | +| A1 | Local plus sun shadow GPU time in heavy views ≤ 2.5 ms (from 6.6 ms). | +| A2 | Heavy-view frame rate ≥ 130 FPS median. 140 is the overall target; the remainder may need L1 or main-pass work. | +| A3 | No new frame-time spikes; CPU render path per frame grows ≤ 0.3 ms. | +| A4 | Final frames visually identical; shadow-atlas tests pass byte for byte. | + +If A1 or A2 is missed, the measurements go to the human with the L1 option. The work is not +declared complete. + +## 10. User documentation + +Update the shadows section of [RenderingFoundation.md](../RenderingFoundation.md): + +- which casters are culled per cluster, and the size threshold; +- memory use; +- the `ADAENGINE_SHADOW_MESHLETS=0` switch. + +Add the switch, and `ADAENGINE_GPU_TIMINGS=1` for measuring, to [Tooling.md](../Tooling.md). Both are +existing guides linked from the documentation index; no new article is needed for an internal path +with no new authoring API. + +## 11. Implementation notes and deviations (pending human review) + +Measured on 2026-11-10: Sponza demo, autopilot, scene at 1890×784, default lighting, RTX 4070, 143 Hz. +"Heavy" means seconds in which the main pass takes more than 2 ms of GPU time. Each run lasts 50 s; GPU +times come from `ADAENGINE_GPU_TIMINGS=1`. + +| Per heavy frame | `ADAENGINE_SHADOW_MESHLETS=0` | Cluster culling | +| --- | ---: | ---: | +| Local shadows (GPU) | 3.2–3.3 ms | 1.2 ms | +| Sun shadow, 1 cascade (GPU) | 2.6–3.3 ms | 2.8–3.2 ms | +| Shadows total (GPU) | 5.8–6.6 ms | 4.0–4.4 ms | +| Frame rate over the run, mean / lowest second | 124–127 / 87–94 FPS | 119–127 / 86–96 FPS | + +- **A1 is not met** (4.0–4.4 ms against 2.5 ms). Local shadows meet their share. The sun does not + improve: the demo uses the default single cascade, an 80 m box centred 40 m ahead of the camera, which + contains the whole model (all 8.8 M opaque triangles) in every frame, so frustum culling has nothing to + remove. A CPU probe of the existing meshlet LOD cut at the 2048-texel tile kept 77% / 76% / 75% of those + triangles at 0.5 / 1 / 2 texels of error, so shadow LOD (§6) cannot close the gap either. +- **A2 is not met.** Heavy views stay at about 90–110 FPS. +- **A3 is met.** Light views hold 143–145 FPS. Per-frame validation of the cluster table and redundant + pipeline/material rebinding were removed after they cost light views about 10 FPS. + +Deviation in §4.2–4.3, **merged runs**: +- One indirect draw per surviving cluster (about 51 K per view for the ivy) stopped the Dawn D3D12 queue: + every submission, the window included, stopped completing. This happened with and without the GPU + draw-count buffer, and no driver reset was logged. +- The cull therefore runs in two steps. `classify` writes visibility per (view, cluster). `emit` writes one + draw per run of up to 1024 consecutive visible clusters of a part. +- Clusters tile their part's index range in order (`MeshletGeometry` validates this), so a run is one + contiguous index range of exactly the visible clusters' triangles. The image argument of §4.5 is unchanged. +- With this change, 6 of 6 runs completed without stalling, against 2 stalls in 4 runs before. + +Not yet done: the tests of §9 (byte-identical shadow atlas, eligibility, fallback), the guide updates +of §10, and the independent review. diff --git a/Documentation/Designs/path-tracing-denoising.md b/Documentation/Designs/path-tracing-denoising.md new file mode 100644 index 000000000..4933abe97 --- /dev/null +++ b/Documentation/Designs/path-tracing-denoising.md @@ -0,0 +1,263 @@ +# Path Tracing Denoising: DLSS Ray Reconstruction and a Portable Denoiser + +Status: Implemented. The deviations and limitations in §11 were accepted by the human on 2026-11-10 ("Accept, then commit"). +Approval: explicit user approval in this Claude Code conversation on 2026-11-10 ("Approve, build it"), including the four decisions of §10. +Date: 2026-11-10. +Classification: improvement of the existing path tracer ([PathTracing.md](../PathTracing.md), +[ADR-0021 hybrid ray tracing](../ArchitectureDecisions/0021-hybrid-ray-tracing.md)) and DLSS bridge ([DLSS.md](../DLSS.md)). +Supersedes items C, D and E of the unapproved +[interactive path-tracing proposal](path-tracing-interactive-quality.md); its items A and B stay there. + +## 1. Problem + +The Sponza path-tracing preset (RTX 4070, WebGPU/D3D12) shows heavy noise and a soft, low-detail image: + +| # | Cause (verified in code or by running the demo) | +| --- | --- | +| P1 | **DLSS never runs in the demo.** NGX needs `nvngx_dlss.dll` beside the executable; the demo build does not stage it. NGX then reports "unavailable on the selected adapter/driver", which hides the real cause. After staging the DLL with `script/build_dlss.py --stage-runtime`, NGX starts and evaluates on this machine. | +| P2 | **No DLSS → reduced resolution.** Without DLSS the demo traces at 0.5× (or 0.25×) and upsamples with a four-tap filter. | +| P3 | **History resets every frame.** The history key contains the view matrix and every light's position. The demo's flying light is on by default, so accumulation never passes 1 sample per pixel even with a still camera. Camera motion also resets: there is no reprojection. | +| P4 | **Minimal denoising.** One 5×5 guided filter. DLSS Super Resolution upscales but does not denoise. | +| P5 | **Low-detail geometry.** The tracer is capped at 131,072 triangles, so Sponza traces a 121 K-triangle LOD, also for primary visibility. The raster G-buffer it already reads holds the full 8.8 M-triangle model. | + +## 2. Decision + +1. **Fix the DLSS runtime delivery (P1)** and report a missing runtime as such. +2. **Hybrid first hit (P5):** take primary visibility, normal, material and position from the raster G-buffer and trace + only from the first bounce on. The image keeps full model detail. The denoisers' guides then match the noisy signal. +3. **Keep history through motion (P3):** history is never reset by camera or light motion. It is reprojected, validated + and blended (§4.3). Only scene, setting and resolution changes reset it. +4. **Denoise with DLSS Ray Reconstruction where available:** native Windows, NVIDIA RTX, with the NGX runtime present. + It denoises and upscales in one step and replaces DLSS Super Resolution for path-traced views. +5. **Denoise with a portable denoiser everywhere else:** an SVGF-style temporal and spatial filter in WGSL/GLSL + (§4.4) for Metal, non-NVIDIA WebGPU and browsers, followed by the existing temporal upscaling (TAA/FSR). + +## 3. Goals and non-goals + +| ID | Goal | +| --- | --- | +| G1 | A still view converges to the same image as today's long accumulation. A moving view or light stays stable without noise crawling. | +| G2 | Path-traced views show full model detail at the primary hit (P5). | +| G3 | DLSS works in the demo and in Studio installs without manual DLL copying (P1). | +| G4 | Every backend gets a denoised image: Ray Reconstruction on RTX, the portable denoiser elsewhere. | +| G5 | The denoiser can be selected, and switched off for reference comparison. | + +Non-goals: + +- Path-tracer speed beyond what the hybrid first hit saves: light sampling (item B), BVH build time and the + triangle cap are separate work. +- Hardware ray tracing, ReSTIR, and DLSS Frame Generation. +- Denoising raster lighting (SSGI/SSR). + +## 4. Design + +### 4.1 DLSS runtime delivery (AdaRender, tooling) + +- `ada_tool install studio`, the Studio packager and the example build docs stage `nvngx_dlss.dll`, + `nvngx_dlssd.dll` and the NVIDIA license beside the executable (`build_dlss.py --stage-runtime`, extended to both DLLs). +- When neither DLL is found beside the executable, the bridge reports "DLSS runtime nvngx_dlss.dll not found + next to " before calling NGX. A development fallback reads the prepared SDK path from `ADAENGINE_DLSS_SDK_PATH` + through NGX's feature path list, so `swift run` of an example works. +- Ray Reconstruction availability is queried separately (`SuperSamplingDenoising_Available`) and exposed as + `RenderDevice.supportsRayReconstruction`. + +### 4.2 Hybrid first hit (path tracer) + +- The tracer runs at the G-buffer resolution, which is the DLSS or temporal input resolution. A pixel's first hit is + the G-buffer surface: view position, normal, roughness and metallic. Base colour comes from a new G-buffer + **albedo** target (RGBA8, written by the main pass when path tracing is enabled), with emission added in the same + target's spare capacity or a second small target. +- Direct light and continuation rays start from that surface, offset along the geometric normal, through the existing + BVH. Secondary bounces still see the LOD geometry; their small-scale error is far below the noise level. +- Sky pixels (no G-buffer surface) take the sky radiance directly. +- The tracer writes **demodulated** irradiance: diffuse and specular radiance divided by their albedo, plus a hit + distance. The portable denoiser filters demodulated light, and Ray Reconstruction receives the remodulated colour + with separate albedos. + +### 4.3 History without resets + +- The reset key keeps scene generation, settings, resolution and sky. It no longer contains the view matrix or light + values. +- Each frame reprojects history with the existing motion vectors. It rejects a history sample when the reprojected + depth or normal differs beyond a threshold (disocclusion) and clamps it to the current neighbourhood's colour range + (moment-based), which removes lag from moving lights. +- The blend weight is `max(1 / (age + 1), αmin)`. `αmin` is 1/32 for the portable path and is configurable, so a + static view converges and moving lights fade old light within about half a second at 60 FPS. +- With Ray Reconstruction, the tracer's own accumulation is off: Ray Reconstruction keeps its own history and + expects one fresh sample per frame. + +### 4.4 Portable denoiser (AdaCorePipelines, WGSL/GLSL through the existing shader pipeline) + +An SVGF-style chain at tracer resolution, run after the tracer: + +1. **Temporal accumulation (§4.3)** of demodulated diffuse and specular, plus the first and second luminance moments + and the history length. +2. **Variance estimate:** temporal variance from the moments, or a 7×7 spatial estimate while history is short. +3. **À-trous wavelet filter:** 4 iterations (step 1, 2, 4, 8). Weights come from depth, normal (power 128), + luminance against filtered variance, and roughness for the specular term. The first iteration's output becomes + the next frame's history. +4. **Remodulation** with albedo, then hand-off to the existing exposure, bloom and temporal upscaling chain (TAA/FSR or + DLSS Super Resolution). + +The existing 5×5 filter and four-tap reconstruction are replaced. The no-denoiser mode +(`PathTracingDenoiser.off`) keeps plain accumulation for reference renders. + +### 4.5 Ray Reconstruction (CWebGPUDLSS bridge) + +- New bridge entry points `ada_dlss_rr_view_create` and `ada_dlss_rr_evaluate`, following the Super Resolution view's + design: shared D3D12 textures imported into Dawn, fence hand-off, three command allocators, no CPU readback. +- **Inputs at render resolution:** + - noisy colour, from the tracer's remodulated output; + - diffuse and specular albedo (G-buffer albedo with metallic, and the existing specular response); + - normals with roughness packed in `.w` (`Roughness_Mode_Packed`, from `normalRoughness3D`); + - hardware depth; + - motion vectors and jitter, as Super Resolution uses them today; + - specular hit distance; + - world-to-view and view-to-clip matrices. +- **Output:** the denoised, upscaled HDR colour, copied back into the resolve texture. Exposure, bloom and grading + continue unchanged. +- The view's temporal path selects Ray Reconstruction instead of Super Resolution when path tracing is active and the + denoiser resolves to `.rayReconstruction`. + +### 4.6 Settings (public API) + +`PathTracing3D.denoiser: PathTracingDenoiser` with `.automatic` (the default), `.rayReconstruction`, `.portable` and +`.off`. `.automatic` picks Ray Reconstruction when the device supports it and DLSS temporal upscaling is enabled on the +camera, and the portable denoiser otherwise. `.rayReconstruction` falls back to `.portable` with a status reason when +unavailable. `Render3DPathTracingStatistics` reports the active denoiser and the fallback reason. + +## 5. Alternatives considered + +| Alternative | Why not | +| --- | --- | +| Ray Reconstruction only | No denoising on Metal, AMD, Intel or the browser (G4). | +| Portable only | Noticeably noisier at 1 sample per pixel than Ray Reconstruction on the hardware that has it. | +| Keep tracing primary rays through the LOD BVH | The guides would not match the colour; the model stays low detail (P5). | +| Raise the triangle cap instead of hybrid first hit | BVH memory, build time and per-ray cost grow with 8.8 M triangles. Hybrid gets full detail for free at the first hit. | +| NVIDIA NRD (ReBLUR/ReLAX) as the portable denoiser | HLSL/compute with a large integration surface and no Metal/browser path in this stack. SVGF fits the existing shader pipeline. | + +## 6. Platform impact + +| Platform | Denoiser | +| --- | --- | +| Windows, NVIDIA RTX, native WebGPU (Dawn D3D12), runtime present | Ray Reconstruction | +| Windows/Linux, other GPUs; macOS (Metal or Dawn) | Portable, then TAA/FSR | +| Browser WebGPU | Portable, then TAA | + +## 7. Ownership + +- `AdaRender`: DLSS runtime discovery and error reporting, Ray Reconstruction device support, and the Swift wrapper + of the Ray Reconstruction view. +- `CWebGPUDLSS`: the Ray Reconstruction view. +- `AdaCorePipelines`: + - the G-buffer albedo target, the hybrid first hit and demodulated output in `path_tracing_3d.glsl`; + - history reprojection and the portable denoiser passes; + - Ray Reconstruction selection in the temporal node. +- Tooling: `build_dlss.py`, `ada_tool` Studio install/packaging, and the example README. + +## 8. Validation and acceptance + +Tests (Swift Testing; GPU suites gated as today): + +- History: a camera move and a moving light keep accumulation (`historyResets` unchanged). Disocclusion rejects + history (a synthetic two-plane scene). +- Hybrid first hit: the primary-hit position equals the G-buffer position within float tolerance, and a + sky pixel takes the sky radiance. +- Portable denoiser: deterministic output for a fixed seed. A still view converges to the `.off` reference + within a bounded error. No NaN or Inf output. +- Golden frames (WebGPU and Metal) for the path-traced test scene, with the portable and `.off` denoiser. +- The DLSS runtime is missing → the stated error message. Ray Reconstruction is unavailable → `.automatic` reports + portable. +- Ray Reconstruction: a Windows GPU test, gated like the existing DLSS smoke, evaluates a frame without device errors. + +Acceptance in the Sponza demo (path-tracing preset, 1890×784 output, default lighting with the flying light, RTX 4070). +The reference is a 4096-frame `.off` accumulation of a still view. + +| ID | Criterion | +| --- | --- | +| A1 | Still view: after 1 s, the portable denoiser is within a small mean relative error of the reference, and Ray Reconstruction is visually indistinguishable from it. Screenshots go in Evidence. | +| A2 | Moving camera and flying light: no visible noise crawl or ghost trails longer than 0.5 s, in recorded clips for both denoisers. | +| A3 | Full-detail primary hits: the ivy and Sponza detail match the raster preset in a side-by-side capture. | +| A4 | DLSS and Ray Reconstruction run in the demo and in an installed Studio without manual steps. | +| A5 | Cost at the demo's DLSS input resolution: portable chain ≤ 1.5 ms of GPU time; Ray Reconstruction measured and reported. The path-tracing frame rate does not drop below today's. | + +## 9. User documentation + +- Update [PathTracing.md](../PathTracing.md): hybrid first hit, history behaviour, the `denoiser` setting, denoiser + selection and limits. +- Update [DLSS.md](../DLSS.md): Ray Reconstruction, both runtime DLLs and the runtime error. +- Update [Tooling.md](../Tooling.md): staging during Studio install. +- Update the DocC rendering article that links path tracing, if it lists settings. + +## 10. Decisions for the human + +1. Approve the **hybrid first hit** (§4.2) with a new G-buffer albedo target. It changes the main pass outputs, but + only when path tracing is enabled. +2. Approve the **SVGF-style portable denoiser** (§4.4) as the replacement for the current 5×5 filter. +3. Approve **`.automatic` defaulting to Ray Reconstruction** whenever DLSS temporal upscaling is enabled on the camera. +4. Approve the **runtime staging** in Studio install/packaging and the development fallback that reads the SDK path + (§4.1). + +## 11. Implementation notes and deviations (pending human review) + +Implemented on 2026-11-10 and measured in the Sponza demo (RTX 4070, Dawn D3D12, scene at 1890×784 with DLSS Quality, +tracer at 1260×523). Captures: `Evidence/PathTracingDenoising-2026-11-10/`. + +Deviations from §4: + +| § | Design | Implemented | Why | +| --- | --- | --- | --- | +| 4.2 | New G-buffer albedo target | Path-traced views reuse the two optional outputs that SSR and SSGI leave unused while tracing: base colour in the diffuse-surface target (RGBA8) and emission in the reflection-response target (RGBA16F), selected by the shader define `ADA_PATH_TRACING_SURFACE` | Every main-pass pipeline (PBR, particles, graphs, terrain, meshlet map) already supports these attachments; a new one would have touched all of them | +| 4.2 | (not specified) | New public component `PathTracingGeometry3D` (`.rasterOnly`, `.pathTracingOnly`) | The full-detail first hit is only visible when the rasterized model can differ from the traced one; the demo's traced LOD would otherwise also be drawn | +| 4.3 | Moment-based clamp; reset on scene, setting, resolution changes | History is clamped to the current 3×3 mean ± 2σ. Light motion keeps history, but adding, removing, switching or retuning a light restarts it | Existing GPU tests require a light switched off or turned away to vanish at once | +| 4.4 | Temporal moments for variance | Spatial 3×3 luminance variance in each wavelet iteration | One RGBA16F target per pass, no MRT pipeline | +| 4.1 | `RenderDevice.supportsRayReconstruction` | `TemporalUpscaler.supportsDenoising`, mirrored to `RenderViewTarget.rayReconstructionAvailable` | The capability belongs to the view's DLSS upscaler, which the tracer must match | + +Also fixed while implementing: +- **Shader cache.** `ShaderCache` recorded a changed source hash before compiling, so a failed compile left a stale + translation in use on every later launch. A failed compile now rolls the entry back. +- **Shader variant gate.** The path-tracing surface variant is requested only when both outputs are attached; + requesting it without them crashed in required-shader loading. + +Acceptance (§8): + +| ID | Status | +| --- | --- | +| A1 | Still views are visually clean with both denoisers (captures). The quantitative comparison against a 4096-frame reference was not run. | +| A2 | Flying light: clean with both denoisers in still captures. Recorded motion clips were not made. The matrices passed to Ray Reconstruction were not validated in motion. | +| A3 | Met: full-detail model and ivy in path tracing (captures). | +| A4 | The demo works with `ADAENGINE_DLSS_SDK_PATH` or staged DLLs. Studio install/packaging staging is implemented but not run. | +| A5 | Portable chain about 1.1 ms of GPU time (42.7 against 41.7 ms for `.off`). Ray Reconstruction cost not measured. The tracer frame rate stays 16–18 FPS. | + +Tests: +- headless: 45 tests in 9 suites, including the new settings, variant-gate and role-extraction tests; +- GPU: `Render3DPathTracing*` 17 tests on WebGPU (`ADAENGINE_GOLDEN_WEBGPU=1`). New tests: portable history survives + camera and light motion but restarts on a light change; `.off` restarts on camera motion; portable output is + finite and within 15% of the converged `.off` reference; Ray Reconstruction falls back to portable with a reason; +- not run: Metal, `check_contribution.py` (SwiftLint is unavailable on Windows), a Ray Reconstruction GPU test. + +### Independent review (2026-11-10) + +Fixed after review: +- `rayReconstructionAvailable` is reset every frame. Ray Reconstruction also requires active temporal upscaling, + so turning DLSS off can no longer leave an undenoised frame. +- Ray Reconstruction traces at its input size regardless of `resolutionScale`, matching decision 3. + Previously the default scale 0.5 kept it from ever running. +- Pixels without a G-buffer surface show the sky directly and are never traced, so tracer-only proxies stay + invisible. +- Ray Reconstruction resets when the tracer's history restarts (scene, light set, settings, denoiser). +- Radiance is clamped before emission is added, so demodulation is exact for bright emitters. +- The runtime lookup cannot throw across the C API. A missing `nvngx_dlssd.dll` disables only Ray Reconstruction, + with a message. +- Doc comments, the Studio staging error and the Metal claim in the guide. + +Remaining deviations and limitations for the human: +1. Reprojection uses the camera's previous matrices and the pixel's world position, not per-object motion vectors. + Moving or skinned objects fail the history check and stay at the noise level of a single frame. +2. No specular hit distance is passed to Ray Reconstruction (§4.5 listed it). +3. The specular albedo guide is F0, not a pre-integrated environment BRDF. +4. The portable history is the temporal output, not the first wavelet iteration (§4.4). +5. First-hit continuation rays start 2 × `rayBias` off the raster surface. A tracer proxy that differs from the + raster model by more than a few millimetres can shadow itself. +6. `PathTracingGeometry3D` is new public API. +7. The view-matrix layout passed to Ray Reconstruction is believed correct but was not validated in motion. diff --git a/Documentation/Designs/path-tracing-interactive-quality.md b/Documentation/Designs/path-tracing-interactive-quality.md new file mode 100644 index 000000000..2d2d0775a --- /dev/null +++ b/Documentation/Designs/path-tracing-interactive-quality.md @@ -0,0 +1,56 @@ +# Interactive path tracing: performance and reduced-resolution quality + +Status: **proposal, awaiting approval**. Scope: the portable shader path tracer +(`PathTracing3DRenderNode`, `path_tracing_3d.glsl`). Hardware ray tracing, the +131,072-triangle scene cap and the material atlas are out of scope here. + +## Problem + +Measured on Sponza (RTX 4070, WebGPU/D3D12, 952×447 rays, 1 spp, 2 interactions): +~28 FPS with 0–12 local lights, independent of light count within noise +([SponzaLightingPerformance](../Performance/SponzaLightingPerformance-2026-11-03.md)). +At the interactive half-resolution preset the image looks soft and noisy, and any +camera motion discards all history. + +Code inspection shows where the time and the quality go: + +1. **Every light, every bounce.** `directLight` enumerates all lights and traces a + shadow ray to each. With the sun and 12 local lights, 2 interactions cost + 2 path rays + 26 shadow rays per pixel per frame. +2. **Primary rays repeat raster work.** The first hit is traced through the BVH + although the raster G-buffer (normals, positions) for the same view is bound. +3. **No reprojection.** The history digest includes the view matrix, so motion + restarts accumulation at one noisy sample. +4. **Fixed upsampling.** Reconstruction is a 2×2 geometry-weighted filter of the + half-resolution result; sub-pixel detail cannot recover over time. +5. **Per-frame GPU allocation.** The light table texture is created every frame. + +## Proposal + +| # | Change | Expected effect | Trade-off | +| --- | --- | --- | --- | +| A | Reuse the light table texture; rewrite it only when lights change | Removes a per-frame allocation (spike source) | None | +| B | Sample one light per bounce (power/distance-weighted, plus the sun) instead of all lights | Shadow rays per pixel drop from 26 to ~4 at 12 lights | More per-frame noise, absorbed by B+C accumulation | +| C | Temporal reprojection: reproject history through the G-buffer position, reject by depth/normal, clamp to a neighborhood | History survives camera motion; motion no longer resets to 1 spp | Ghosting must be bounded; adds one pass | +| D | Temporal upsampling: jittered sub-pixel ray origins accumulated at output resolution (TAAU-style) | Half-resolution rays converge toward full-resolution detail | Needs C; first frames after disocclusion stay soft | +| E | Hybrid first hit from the G-buffer (needs hit material id/albedo in the G-buffer) | Saves the primary traversal | G-buffer format change; largest change, optional | + +Recommended order: A (bug-level, no visual change) → B → C → D; E only if +measurements still show primary traversal dominating. + +## Validation + +- GPU tests (WebGPU and Metal): history survives a small camera move; disocclusion + rejects stale history; light-table texture identity is stable while lights are + static; deterministic output with a fixed seed. +- Opt-in Sponza benchmark: frame time and static/moving convergence images at + quarter and half resolution, before/after each step, same machine and route. +- User guide: the path-tracing settings article documents the new behavior and + any new settings (e.g. light samples per bounce). + +## Decisions needed + +1. Approve B (stochastic light sampling) as the default, or keep exhaustive + lighting as an option (`lightSamplesPerBounce = 0` meaning "all")? +2. Approve C+D (temporal reprojection and upsampling) as the interactive default? +3. Include E (G-buffer change) now, or defer? diff --git a/Documentation/Designs/pipelined-rendering.md b/Documentation/Designs/pipelined-rendering.md index b9add6385..197f943b1 100644 --- a/Documentation/Designs/pipelined-rendering.md +++ b/Documentation/Designs/pipelined-rendering.md @@ -207,3 +207,44 @@ This is a user-facing option, so it ships with documentation: | D3 | Material snapshot at extraction, rather than locking inside `Material` (§5, R3). | | D4 | First version enables pipelining only for SceneView render worlds. The UI window stays serial. | | D5 | The §8 render-path items are separate improvements, measured one by one after this lands. | + +## 13. Implementation notes and deviations (pending human review) + +Revision 2 (2026-11-09), written during implementation. Items marked **deviation** differ from the text above. + +- **Deviation, §4.5 API:** SceneView uses a modifier, `.pipelinedRendering()`, like `frameCaptureLabel(_:)`, + instead of an initializer parameter. +- **Deviation, §4.3 executors:** a per-world executor for the graph was not enough. The `RenderNode.execute` + requirement itself was `@RenderGraphActor`, so every node of every graph still ran on one actor. The + requirement is now nonisolated: a pipelined graph runs on the concurrent executor, a serial graph is still + started from `RenderGraphActor`, and nodes of one graph still run one after another. Nothing else used + `RenderGraphActor`. +- **Deviation, R5:** not enforced at runtime. A `@MainActor` system in a pipelined render world hops to the + main actor, so it cannot race with main-actor state, but it can delay the main loop. SceneView's own + main-actor `FrameSubmissionSystem` is replaced in pipelined mode by a main-world system that checks + the main world's cameras; the render-world version would have dropped frame N+1's reservation while frame N + was still rendering. The UI window's `UIRenderPreparing` stays in its serial render world (D4). +- **R1/R2:** `MainWorld` is removed after `.extract` in pipelined worlds; `Extract` asserts in debug builds + when read later. The only system that read the main world after extraction, `CreateWindowSurfacesSystem`, + now reads `PrimaryWindowId` and `WindowFrameRequests` copied at extraction. +- **R3, D3:** `PBRMaterial.stateRevision` changes with every stored property; pipelined extraction replaces + materials in the extracted sources with `renderSnapshot()` copies, reused until the revision changes. + Subclasses (`AtmosphereMaterial`) are passed through unchanged. Meshes and textures follow the documented + replace-not-mutate rule; there is no runtime check. +- **R4:** the render statistics resources already use `Mutex`. `RenderGraphDiagnostics` is unsynchronized + but is only read through `withExclusiveWorldAccess`, which now waits for the frame in flight. +- **§8, first item:** release builds pass Dawn `skip_validation` (`ADAENGINE_WEBGPU_VALIDATION=0|1` overrides). + +### Measurements so far (Sponza, flying light off, 0 local lights, release) + +| Scene size | Serial | Pipelined | GPU busy (pipelined) | +| --- | ---: | ---: | ---: | +| 1170×484 | — | 106 FPS | 73% | +| 1890×784 | 95 FPS | 103–108 FPS | 90% | +| 2850×1204 | 94 FPS | 97 FPS | 100% | + +The SceneView step on the main thread drops from ~8 ms to ~1 ms, so the overlap works. Two limits remain: +the scene render path takes ~9.5 ms when overlapped (it encodes ~0.7 ms slower while the UI graph runs at +the same time), and the GPU needs ~9 ms per frame at 1890×784. `skip_validation` made no measurable +difference at this point. Criterion 1 (≤ 8.0 ms) is **not met yet**. Lower shadow resolution, coarser +meshlet LOD and disabled meshlets each changed the result by less than 3%. diff --git a/Documentation/PathTracing.md b/Documentation/PathTracing.md index 658b3fd2f..91d6f1d9d 100644 --- a/Documentation/PathTracing.md +++ b/Documentation/PathTracing.md @@ -40,15 +40,62 @@ come from the same current CPU poses used to prepare raster skinning, not GPU buffer mapping. Cast/receive flags and alpha tests are respected; alpha blending uses stochastic straight-through coverage, without glass refraction. -Each camera owns two HDR accumulation textures and one spatial filter target. Unjittered camera/projection, -geometry/pose/material/light/sky changes reset accumulation; presentation jitter -can still vary between accumulated samples. Resize and disabling release a -view's history. Changed geometry refits the CPU hierarchy; count changes and -periodic rebuilds refresh its partitioning. Bounds/centers are cached during -building. A normal/depth/roughness-guided 5x5 filter with bounded anti-firefly -weighting runs after raw accumulation, retaining sharp mirror samples. A -geometry-aware four-tap reconstruction follows it; DLSS/TAA can reconstruct the -finished frame using the existing raster depth and motion guides. +## First hit from the G-buffer + +On devices with the extra G-buffer outputs (verified on native WebGPU; Metal is not yet verified), a traced view takes its first hit +from the raster G-buffer: position, normal, roughness and metallic as before, plus base colour and emission, +which the main pass writes for traced views only. Paths start tracing at the first bounce. The image keeps +the full detail of the rasterized model, and the denoisers' guides match the traced light exactly. Pixels the raster +pass left empty show the sky directly, so tracer-only proxies are never visible. Devices without the outputs trace +their primary ray as before. +`Render3DPathTracingStatistics` reports this as `hybridFirstHit`. + +Because the first hit comes from raster, a scene can draw a detailed model while the tracer intersects a lighter +proxy for bounces and shadows. `PathTracingGeometry3D` marks an entity and its descendants: + +```swift +detailed.components += PathTracingGeometry3D.rasterOnly // drawn, not traced +proxy.components += PathTracingGeometry3D.pathTracingOnly // traced, never drawn +``` + +The Sponza example rasters the full 8.8 M-triangle model and traces its 122 K-triangle lighting LOD this way. + +## Denoising + +`PathTracing3D.denoiser` selects how a view removes the noise of its few samples per pixel: + +| Value | Behaviour | +| --- | --- | +| `.automatic` (default) | DLSS Ray Reconstruction when the camera uses DLSS temporal upscaling on a supported NVIDIA GPU and the tracer runs at input size (`resolutionScale` 1); otherwise `.portable`. | +| `.rayReconstruction` | Ray Reconstruction, falling back to `.portable` with `denoiserFallback` set in the statistics. | +| `.portable` | Temporal reprojection and an edge-aware wavelet filter, on every backend. | +| `.off` | Plain accumulation of a still view, for reference renders. | + +**Portable denoiser.** The tracer divides its light by the surface's base colour (so texture detail is never blurred), +then: + +1. reprojects last frame's light through the world position; +2. rejects it where normal or camera distance disagree; +3. clamps it to the current neighbourhood's mean ± 2σ; +4. blends with weight `max(1/frames, minimumHistoryWeight)` (default 1/32); +5. filters with four à-trous iterations guided by normal, depth and local luminance variance; +6. multiplies the base colour back and adds emission. + +Camera and light motion keep history; adding, removing, switching or retuning a light, scene, material or setting +changes restart it. On an RTX 4070 at 1260×523 the chain costs about 1.1 ms of GPU time. + +**Ray Reconstruction.** The tracer writes one fresh sample per frame and diffuse albedo, specular albedo and world +normals with roughness as guides. DLSS Ray Reconstruction then denoises and upscales in one step, replacing Super +Resolution for that view. See [DLSS](DLSS.md) for its runtime. + +**`.off`.** Plain accumulation restarts on any camera, projection, geometry, pose, material, light or sky change. + +Ray Reconstruction always traces at its DLSS input size; `resolutionScale` applies to the other denoisers. +The portable history age is capped at 1024 frames. + +Each camera owns its accumulation, guide and filter textures; resizing or disabling releases them. Changed +geometry refits the CPU hierarchy; count changes and periodic rebuilds refresh its partitioning. A geometry-aware +four-tap reconstruction brings a reduced `resolutionScale` back to output resolution. `Render3DPathTracingStatistics.snapshots` reports actual encoded frames, resets, accumulation, triangles, lights, traced dimensions, implementation name and a @@ -70,10 +117,11 @@ Current limits: address modes, mip gradients, HDR material texture values and in-place texture edits are not reproduced. Replacing a texture refreshes its atlas tile. - Emissive surfaces and the environment have no next-event importance sampling; - small emitters can be very noisy. There is no ReSTIR/MIS or dedicated temporal - denoiser. Radiance clamping, anti-firefly filtering and finite path length introduce bias. -- Continuous camera/pose changes restart static accumulation. Temporal - reconstruction is not a replacement for a lighting denoiser. + small emitters can be very noisy. There is no ReSTIR/MIS. Radiance clamping and finite path length introduce bias. +- The denoisers trade noise for lag: the portable history weight and clamp follow moving lights within a few frames, + and disocclusions start from one sample. Every light is still sampled at every bounce, which dominates trace cost. +- The G-buffer first hit uses base metallic/roughness, base colour and emission; clearcoat, sheen and anisotropy + are not reproduced there either. - Cosmetic shader graphs, particles, analytic/volumetric fog, raster depth/motion guides and overlays remain in the existing renderer. This is surface transport, not a completely ray-traced renderer for every effect. diff --git a/Documentation/Performance/SponzaFlyingLights-2026-11-08.md b/Documentation/Performance/SponzaFlyingLights-2026-11-08.md new file mode 100644 index 000000000..538fdf7f9 --- /dev/null +++ b/Documentation/Performance/SponzaFlyingLights-2026-11-08.md @@ -0,0 +1,114 @@ +# Sponza: moving shadowed lights and frame spikes — 2026-11-08 + +Windows, RTX 4070, WebGPU/Dawn D3D12, release + `-enable-testing`, 1280×720, +`sponza-base` PBR, 300 measured frames per route, one round. Benchmark: +`Render3DGPUTests/SponzaBenchmark` with the new `ADAENGINE_SPONZA_FLYING_LIGHTS=N` +(N shadow-casting point lights, range 10 m, circling the camera at 2.5 m). + +## Moving lights cost CPU encoding, not GPU + +| Route | No flying lights | 4 flying lights | CPU update p50 (none → 4) | +| --- | ---: | ---: | ---: | +| atrium | 8.2 ms | 15.5 ms | 6.3 → 14.0 ms | +| ground-floor-corridor | 9.2 ms | 16.5 ms | 7.8 → 14.7 ms | +| upper-gallery | 9.8 ms | 17.6 ms | 7.7 → 15.5 ms | + +A moving light changes all six face matrices every frame, so every face is +re-culled and redrawn: 24 faces per frame for 4 lights. Stall probes +(`ADAENGINE_STALL_PROBE=1`, 1 ms threshold, atrium) show local shadows at +7.4 ms/frame: `LocalShadow.EncodeDraws` 5.0 ms for ~2,250 draws (~2.2 µs each) +and `LocalShadow.PrepareFaces` 1.6 ms. Bind groups are already cached and an +unchanged pipeline is not re-set, so the cost is the draw count times ordinary +per-draw encoding work. + +**Change made:** faces whose volume intersects neither the camera frustum nor +the dynamic-irradiance surface bounds are skipped and marked stale (they redraw +once sampled). This is lossless but saved only 0.2–5% of faces here, because +lights near the camera reach the view through almost every face. + +## Spikes: synchronous pipeline compilation + +The same run shows six `WGPU.CreateRenderPipeline.Flat 3D Pipeline` compiles of +364–389 ms each (2.2 s total) on first use, plus ~28 ms per directional/local +shadow pipeline variant, and three directional-shadow recompiles when Hi-Z is +toggled. Any variant first used mid-flight blocks a frame for hundreds of +milliseconds. There is no persistent pipeline cache: every launch recompiles. +Swan exposes Dawn's `DawnCacheDeviceDescriptor` (load/store callbacks), so a +disk cache is feasible. + +### Persistent pipeline cache (implemented 2026-11-09) + +Native WebGPU now passes Dawn a disk-backed blob cache (`PipelineDiskCache`, +`/AdaEngine/PipelineCache/`), so translated and compiled shaders +survive restarts. Same benchmark (atrium, no flying lights, stall threshold 20 ms), +two launches against one initially empty cache folder: + +| | Cold launch (empty cache) | Warm launch | +| --- | ---: | ---: | +| Pipeline-creation stalls > 20 ms | 8, 1,505 ms total (worst: Flat 3D 571 ms, SSR composite 499 ms) | 0 | +| First rendered frame | 1,211 ms | 635 ms | +| Later whole-frame stalls | 148 ms, 184 ms | 39 ms | +| Cache size | 63 files, 1.7 MB | — | + +The remaining first-frame time is the first submit and GPU completion +(`WGPU.SubmitCPU`, `WGPU.CompletionLatency`, initial uploads), not compilation. +Average frame times of these single-round runs (8.6–11.4 ms) are within run-to-run +noise and are not a cache effect. A cold launch (new machine, new driver, Dawn or +shader change) still compiles synchronously; option 2 below addresses that. + +### Batched shadow encoding (2026-11-09) + +Both CPU-bound: 320×180 is no faster than 720p (base 7.9 vs 8.4 ms, 4 lights 17.9 vs +16.0 ms). Temporary per-step timers in the local-shadow loop (2,330 draws/frame, +4 lights) split 5.5 ms of encoding into pipeline/material selection 2.1 ms, bind-group +commit + draw 1.6 ms, buffer binds 0.8 ms, material resource sets 0.6 ms. + +`ShadowBatchEncoder3D` (directional cascades and local faces) now resolves each batch's +pipeline and bindings once per frame, groups a face's draws by state (shadow depth is +order-independent), binds the instance buffer once per face and selects batches with +`drawIndexed(firstInstance:)`; the WebGPU encoder skips redundant index-buffer binds. + +| atrium, 720p | Before | After | +| --- | ---: | ---: | +| No flying lights | 8.4 ms | 7.6 ms | +| 4 flying lights | 16.0 ms | 12.8 ms | + +Lossless check: the full WebGPU GPU validation run (344 tests) fails the same 25 issues +with identical differing-pixel counts on the committed renderer and with this change; +those golden mismatches are pre-existing on this machine. + +### GPU-driven shadow batching (2026-11-09) + +[Design](../Designs/gpu-driven-shadow-batching.md). Static casters are registered once, their geometry is +copied into shared buffers per vertex layout, one compute pass culls them for every shadow view, and each +view draws them with one multi-draw per draw group (`multi-draw-indirect` is available on this RTX 4070 / +D3D12 adapter). Same benchmark, 300 frames, Hi-Z off unless stated: + +| atrium, 720p | ms/frame | +| --- | ---: | +| No flying lights (2 rounds) | 7.25, 7.43 (mean 7.34) | +| 4 flying lights, GPU path + multi-draw (2 rounds) | 8.34, 8.51 (mean 8.42) | +| 4 flying lights, GPU culling, one indirect draw per mesh | 14.07 | +| 4 flying lights, GPU path off (`ADAENGINE_SHADOW_GPU=0`) | 14.57 | + +Four moving shadowed lights now add about 1.1 ms instead of 5.2 ms (design target: 1.0 ms). Without +multi-draw the gain disappears here, because Sponza's ~100 parts per face still mean ~100 draw calls per +face. This session's run has more shadow faces per measurement than the 2026-11-09 batching run above +(7,075 vs 5,659 drawn), so compare within one table only. + +Lossless check: the full WebGPU validation (348 tests) gives the same issue list and differing-pixel counts as +the committed renderer with the GPU path on, with multi-draw forced off, and with the GPU path off; a new scene +test compares GPU-path and CPU-path frames exactly across light and caster motion and partial face reuse. + +## Options (need a decision) + +1. Persistent Dawn pipeline/shader cache on disk: removes repeat-launch compiles; + first launch unchanged. +2. Asynchronous pipeline creation with load-time warm-up of known variants: + removes mid-flight compile spikes; a not-yet-ready variant must either wait + or be skipped for a frame. +3. Time-sliced updates for moving lights (e.g. redraw a bounded number of faces + per light per frame, most relevant first): bounds CPU cost; shadows of moving + lights lag by up to a few frames. +4. Lower per-draw cost by preparing draw state once per item per frame and + sharing it across the faces that draw the item (lossless, gain unmeasured). diff --git a/Documentation/Plans/2026-11-09-GPUDrivenShadowBatching.md b/Documentation/Plans/2026-11-09-GPUDrivenShadowBatching.md new file mode 100644 index 000000000..fef9426b9 --- /dev/null +++ b/Documentation/Plans/2026-11-09-GPUDrivenShadowBatching.md @@ -0,0 +1,31 @@ +# GPU-Driven Shadow Batching — Implementation Plan + +Design: [gpu-driven-shadow-batching.md](../Designs/gpu-driven-shadow-batching.md), approved 2026-11-09. +Task: `gpu-shadow-batching` (`script/check_contribution.py`). + +## Steps + +| # | Step | Files | Check | +| --- | --- | --- | --- | +| 1 | `multiDrawIndexedIndirect` encoder requirement (WebGPU native; others return false), `supportsMultiDrawIndirect`, WebGPU feature request (`multiDrawIndirect` + `indirectFirstInstance`), `ADAENGINE_SHADOW_MULTIDRAW=0`, index buffers usable as copy sources | `AdaRender/Backends/CommandEncoder.swift`, `RenderBackend.swift`, `WebGPU/WebGPURenderDevice.swift`, `WebGPU/WebGPURenderBackend.swift`, `WebGPU/Misc/WGPURenderCommandEncoder.swift` | WebGPU pixel test: multi-draw equals separate indirect draws | +| 2 | `GPUShadowCullProcessor` protocol and data layout; WebGPU compute implementation | `AdaRender/Misc/GPUShadowCull.swift`, `WebGPU/Misc/WGPUShadowCullProcessor.swift`, `WGPUShadowCullShaders.swift` | Input validation unit tests; GPU test: culled set ⊇ CPU frustum set (A2) | +| 3 | `ShadowCasterRegistry3D`: eligibility, geometry pool (byte copies per vertex layout), draw groups, instance records, change bounds | `AdaCorePipelines/CorePipelines/ShadowCasterRegistry3D.swift`, `Core3DPlugin.swift` | Unit tests: eligibility, segments, change bounds, pool reuse/rebuild (A4) | +| 4 | Culling and drawing in both shadow nodes, with CPU fallback and per-face reuse | `GPUShadowCull3D.swift`, `RenderNodes3D/LocalShadow3DRenderNode.swift`, `RenderNodes3D/DirectionalShadow3DRenderNode.swift`, `LocalLighting3D.swift`, `Lighting3D.swift` | Shadow atlas parity GPU vs CPU path (A3); existing face-cache tests | +| 5 | Golden runs (A1) with GPU path on, multi-draw off, GPU path off; Sponza benchmark (A5) | — | `script/validate_webgpu.sh`; `Render3DGPUTests/SponzaBenchmark` | +| 6 | Docs: `Documentation/RenderingFoundation.md` Shadows, `Documentation/Tooling.md`, performance note | docs | Review | + +## Implementation choices within the design + +- **Geometry pool:** each mesh part's original vertex and index bytes are copied on the GPU into one shared + buffer pair per vertex layout, instead of a converted fixed layout. The shadow pipeline variant is the one + the CPU path already uses for that layout, so shaders are unchanged and the inputs are byte-identical. + Meshes always use 32-bit indices; other index formats keep the CPU path. +- **Instance selection:** culling copies the first 128 bytes of each surviving instance record into a + compacted per-view buffer, as the existing Hi-Z processor does. The shadow vertex shader is unchanged. +- **Opaque material independence:** instead of a new per-instance flag, opaque casters bind one opaque + material buffer with matching sidedness per draw group. In opaque mode the shadow fragment shader reads only + the sidedness and alpha-mode fields, which are equal within a group. +- **Compute shaders** follow the existing visibility processors: WGSL in `AdaRender`, created by the render + device. Native Metal and headless return no processor and keep the CPU path (same image, no speedup). +- **Face reuse:** a point/spot face kept from an earlier frame is redrawn when a GPU-path caster changed + inside it (old or new bounds against the face frustum), preserving today's partial redraw. diff --git a/Documentation/RenderingFoundation.md b/Documentation/RenderingFoundation.md index eb03201f2..f5f419565 100644 --- a/Documentation/RenderingFoundation.md +++ b/Documentation/RenderingFoundation.md @@ -65,6 +65,31 @@ excluded. The R32F depth copy supports nonfilterable sampling. Maximum 32 steps, skip this supplement. At 1080p its color/depth pair takes approximately 25 MB plus an additional geometry pass; disabled views allocate neither target. +### Shadow casting cost + +Shadow maps are redrawn every frame they change; a moving point light redraws +all six of its faces. With WebGPU (native and browser), ordinary static casters +are culled on the GPU for every shadow view at once (point and spot faces, sun +cascades), so the CPU no longer culls them per view. The shadow image is +identical to the CPU-culled path. + +- **Casters that stay CPU-culled:** skinned or morphing meshes, meshes with + level-of-detail alternatives, meshes with a finite maximum draw distance, + alpha-masked shader-graph materials, and meshes without 32-bit indices. Their + per-view result depends on CPU state. Blended materials cast no shadows, as before. +- **Draw calls:** where the native adapter supports Dawn's multi-draw + (`multi-draw-indirect` with `indirect-first-instance`), one call draws each + group of meshes per view, which removes most of the CPU cost. Without it + (the browser, or adapters lacking those features) each mesh is still one + indirect draw per view, so the saving is small. Native Metal and headless keep + the CPU-culled path. +- **Memory:** each static caster's vertex and index data is copied once into + shared shadow buffers, one pair per vertex layout, with up to 50% headroom for + growth (at least 64 KiB each), plus per-view buffers of the culled instances. + A layout whose shadow geometry would exceed 256 MiB keeps the CPU path. +- **Switches:** `ADAENGINE_SHADOW_GPU=0` draws every caster on the CPU-culled path; + `ADAENGINE_SHADOW_MULTIDRAW=0` keeps GPU culling but issues one draw per mesh. + ## Local volumetrics Up to eight overlapping world-axis-aligned boxes add density and scattering diff --git a/Documentation/Tooling.md b/Documentation/Tooling.md index b2d849e1b..478075852 100644 --- a/Documentation/Tooling.md +++ b/Documentation/Tooling.md @@ -128,12 +128,80 @@ python ada_tool.py build studio --debug --scratch-path /path/to/isolated-build `build engine` compiles the AdaEngine target. `build studio` prepares native dependencies and builds the app; it prints the binary directory or macOS `.app`. Use `install studio` for a complete runnable distribution on Windows/Linux. +On Windows, a build linked with the DLSS bridge (`script/build_dlss.py`) copies NVIDIA's +`nvngx_dlss.dll`, `nvngx_dlssd.dll` and license next to the executable, and the packager includes them; +see [DLSS](DLSS.md). Build logs are saved in the host CLI cache. Build stages appear in the terminal as they run; the last 30 log lines are also printed when compilation finishes. A failing compiler exits nonzero with the log location. Default builds use their own scratch directory, leaving the shared SwiftPM `.build` intact. If an explicitly reused scratch cache belongs to another compiler, choose a new `--scratch-path`. + +## Profile Studio with native CPU profilers + +`--profile` builds the release configuration with debug symbols, so sampling +profilers show Swift function names while timing stays representative: + +```sh +python ada_tool.py install studio --profile --launch +python ada_tool.py run studio --profile +python ada_tool.py build engine --profile +``` + +The profiling installation lives beside the release and Debug ones (for example +`%LOCALAPPDATA%/Programs/Ada Studio Profile` on Windows) and uses its own +`-profile` build cache, so it never invalidates the release cache. +`--profile` cannot be combined with `--debug`. + +- **Windows:** symbols are CodeView PDBs next to the build products; the + executables reference them by absolute path, so keep the build cache while + analyzing. Record with Windows Performance Recorder, reproduce the slow action, + and open the trace in Windows Performance Analyzer (CPU Usage (Sampled), + grouped by stack): + + ```sh + wpr -start CPU -filemode + wpr -stop studio.etl + ``` + + Superluminal and the Visual Studio profiler read the same PDBs. +- **macOS:** the Release app includes a dSYM; use Instruments' Time Profiler. +- **Linux:** DWARF symbols are embedded; use `perf record -g` and `perf report`. + +For coarse frame timing without a profiler, set `ADAENGINE_STALL_PROBE=1` +(optionally `ADAENGINE_STALL_THRESHOLD_MS=4`): operations slower than the +threshold print tab-separated `ADA_STALL` lines with their duration. +Besides engine phases (`App.Update.*`, `RenderNode.*`, `WGPU.*`), Studio reports +`Studio.OpenFile`, `Studio.SceneDecode`, `Studio.Highlight` and +`Studio.ProjectTree.Refresh` (columns: start ms, duration ms, phase, bytes, count). + +### WebGPU pipeline cache + +Native WebGPU (Dawn) builds keep translated and compiled shaders on disk, so a +pipeline variant compiled once is not compiled again on the next launch. The first +launch on a machine, and the first launch after a driver, Dawn or shader change, +still compiles; `WGPU.CreateRenderPipeline.*` stall lines show those compiles. + +- Location: `%LOCALAPPDATA%/AdaEngine/PipelineCache/` on Windows and the + user caches directory elsewhere (for example `~/Library/Caches` on macOS), one + folder per GPU, backend and driver. +- At startup the current folder is trimmed to 512 MB, least recently used entries + first, and adapter folders unused for 30 days (for example after a driver update) + are removed. Deleting the cache is always safe. +- `ADAENGINE_PIPELINE_CACHE=0` disables it (for measuring cold compiles); + `ADAENGINE_PIPELINE_CACHE_DIR=` stores it elsewhere. + +### GPU-driven shadows + +Static shadow casters are culled on the GPU with WebGPU (see +[shadow casting cost](RenderingFoundation.md#shadow-casting-cost)). For comparisons +and bug reports: + +- `ADAENGINE_SHADOW_GPU=0` draws every caster through the CPU-culled path. +- `ADAENGINE_SHADOW_MULTIDRAW=0` keeps GPU culling but replaces multi-draw with one + indirect draw per mesh. + ## Playground and WebAssembly ```sh diff --git a/Package.resolved b/Package.resolved index b9d207ae1..780688c1c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ea262deac1e12a812cdee2938fef61f6570cee69af125a726466c02ed72f26c2", + "originHash" : "c981a41efd56a081537f4f7b35a4767b79479c5b3a8e43af7e82d33039e450f2", "pins" : [ { "identity" : "swan", diff --git a/Sources/AdaApp/AppBuilder.swift b/Sources/AdaApp/AppBuilder.swift index b9063fd15..ac7338681 100644 --- a/Sources/AdaApp/AppBuilder.swift +++ b/Sources/AdaApp/AppBuilder.swift @@ -35,6 +35,9 @@ public final class AppWorlds { /// Assign on the root of an embedded game. Nested worlds inherit its profiling context. public var profilingTargetID: String? + /// Receives the variable game tick and whether continuous simulation is active. + package var gameDiagnosticsTick: (@MainActor (Bool) -> Void)? + /// Skips this runtime in its host's automatic frame loop while retaining it /// for tooling and explicit updates (for example an inactive editor preview). package var isHostedUpdateSuspended = false @@ -55,6 +58,9 @@ public final class AppWorlds { /// The world extractor. nonisolated(unsafe) var worldExctractor: (any WorldExctractor)? + /// The in-flight update of a pipelined subworld (see ``RenderPipelining``). + var pipelinedUpdate: Task? + /// The plugins. @usableFromInline var plugins: ContiguousArray = [] @@ -106,6 +112,18 @@ extension AppWorlds { self.runner = block } + /// Tooling sees a consistent state only after in-flight pipelined renders finish. A failed render is + /// logged here instead of failing the unrelated tooling operation. + private func waitForPipelinedUpdatesAtSafePoint() async throws { + do { + try await waitForPipelinedUpdates() + } catch is CancellationError { + throw CancellationError() + } catch { + logger.error("Pipelined update failed: \(error)") + } + } + /// Executes a synchronous tooling operation between frame updates. /// Call on the root AppWorlds so subworld schedulers and extraction are also covered. /// Do not call from a running system: it would wait for its own frame to finish. @@ -113,6 +131,7 @@ extension AppWorlds { while activeUpdates > 0 { await withCheckedContinuation { updateWaiters.append($0) } } + try await waitForPipelinedUpdatesAtSafePoint() try Task.checkCancellation() return try operation() } @@ -124,6 +143,7 @@ extension AppWorlds { while activeUpdates > 0 { await withCheckedContinuation { updateWaiters.append($0) } } + try await waitForPipelinedUpdatesAtSafePoint() try Task.checkCancellation() activeUpdates += 1 defer { @@ -200,6 +220,7 @@ extension AppWorlds { let simulationControl = main.getOrInitRefResource(SimulationControl.self) { SimulationControl() } + gameDiagnosticsTick?(simulationControl.wrappedValue.mode == .running) let shouldRunMainWorld: Bool switch simulationControl.wrappedValue.mode { case .running: @@ -220,10 +241,17 @@ extension AppWorlds { } for world in self.subWorlds.values where !world.isHostedUpdateSuspended && world.hostedUpdateSuspensionCount == 0 { + // A pipelined subworld may still render the previous frame; extraction is the only meeting point. + let pipelined = world.isUpdatePipelined + try await world.waitForPipelinedUpdates() let stall = PerformanceStallProbe.begin() unsafe await world.worldExctractor?.exctract(from: main, to: world.main) if stall != nil { PerformanceStallProbe.end("App.Extract.\(world.main.name ?? "subworld")", since: stall) } - try await world.update() + if pipelined { + world.startPipelinedUpdate() + } else { + try await world.update() + } } // UI lifecycle callbacks can enqueue removals while a subworld is @@ -241,6 +269,7 @@ extension AppWorlds { return } try await withExecutionContext { + try await world.waitForPipelinedUpdates() unsafe await world.worldExctractor?.exctract(from: main, to: world.main) try await world.update() } diff --git a/Sources/AdaApp/RenderPipelining.swift b/Sources/AdaApp/RenderPipelining.swift new file mode 100644 index 000000000..a32f12680 --- /dev/null +++ b/Sources/AdaApp/RenderPipelining.swift @@ -0,0 +1,80 @@ +import AdaECS +import Foundation + +/// Opts a subworld, normally a render world, into pipelined updates. +/// +/// A pipelined subworld runs one frame behind its parent. The parent extracts frame N into it, +/// starts its update and continues with frame N+1 without waiting. Before the next extraction the +/// parent waits for that update to finish, so extraction remains the only point where both worlds +/// meet. Frame time approaches the longer of the two paths instead of their sum, at the cost of +/// one frame of input latency. +/// +/// While a pipelined update is in flight, systems of the subworld must not read the parent world +/// (see `Extract` and `MainWorld`), and parent systems must not mutate objects the subworld reads. +/// Set `ADAENGINE_PIPELINED_RENDERING=0|1` to override the setting of every world that declares this +/// resource, for measurements. +/// Pipelining is unavailable on WebAssembly, where the setting is ignored. +public struct RenderPipelining: Resource, Sendable { + /// Whether this subworld updates one frame behind its parent. + public var isEnabled: Bool + + public init(isEnabled: Bool) { + self.isEnabled = isEnabled + } + + /// Whether pipelining is in effect for a world declaring this setting, after the environment override. + public var isActive: Bool { + #if WASM + return false + #else + return Self.environmentOverride ?? isEnabled + #endif + } + + /// Whether `world` belongs to a pipelined subworld. Usable where only the `World` is available. + public static func isActive(in world: World) -> Bool { + world.getResource(RenderPipelining.self)?.isActive ?? false + } + + /// The process-wide override from `ADAENGINE_PIPELINED_RENDERING`, if set. + static let environmentOverride: Bool? = { + switch ProcessInfo.processInfo.environment["ADAENGINE_PIPELINED_RENDERING"] { + case "1": true + case "0": false + default: nil + } + }() +} + +extension AppWorlds { + /// Whether this world's updates run one frame behind its parent. + public var isUpdatePipelined: Bool { + #if WASM + return false + #else + // Only worlds that declare the resource are candidates; the override never pipelines other worlds. + return RenderPipelining.isActive(in: main) + #endif + } + + /// Waits for this world's and its subworlds' in-flight pipelined updates. + /// + /// Tooling access, extraction and teardown call this before touching state that a pipelined + /// update may still read. Errors of the finished update are rethrown. + public func waitForPipelinedUpdates() async throws { + if let pending = pipelinedUpdate { + pipelinedUpdate = nil + try await pending.value + } + for world in subWorlds.values { + try await world.waitForPipelinedUpdates() + } + } + + /// Starts this world's update without waiting for it. The caller waits with ``waitForPipelinedUpdates()``. + func startPipelinedUpdate() { + pipelinedUpdate = Task { @MainActor [self] in + try await update() + } + } +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl index 9d65174b5..442a59261 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.glsl @@ -1338,4 +1338,10 @@ void flat3d_fragment() #endif normalRoughness = vec4(normal, roughness); viewPositionMetallic = vec4(Input.ViewPosition, metallic); +#if defined(ADA_PATH_TRACING_SURFACE) && defined(ADA_DIFFUSE_SURFACE) && defined(ADA_HYBRID_REFLECTION) + // Path-traced views start their paths at this surface: base colour and emission replace the + // raster diffuse/specular responses, which screen-space GI and reflections do not use while tracing. + diffuseSurface = vec4(clamp(baseColor.rgb, vec3(0.0), vec3(1.0)), opacity); + reflectionResponse = vec4(u_SurfaceProperties.w > 1.5 ? vec3(0.0) : emissive, opacity); +#endif } diff --git a/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.glsl b/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.glsl index d7b5e4113..1c62799ac 100644 --- a/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.glsl +++ b/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.glsl @@ -21,6 +21,10 @@ layout(binding=8) uniform sampler u_Nearest; layout(binding=9) uniform sampler u_Linear; layout(binding=10) uniform texture2D u_Normal; layout(binding=11) uniform texture2D u_Position; +layout(binding=12) uniform texture2D u_Albedo; // G-buffer base colour (path-tracing surface outputs) +layout(binding=13) uniform texture2D u_Emission; // G-buffer emission +layout(binding=14) uniform texture2D u_InputB; // pass-specific second input +layout(binding=15) uniform texture2D u_InputC; // pass-specific third input layout(binding=4) uniform PathUniform { mat4 u_InverseProjection; mat4 u_InverseView; @@ -31,6 +35,10 @@ layout(binding=4) uniform PathUniform { vec4 u_Horizon; vec4 u_Ground; vec4 u_Sky; // environment texture enabled, intensity, rotation, procedural enabled + vec4 u_Hybrid; // x: first hit from the raster G-buffer + mat4 u_PreviousViewProjection; // unjittered, previous frame of this view + mat4 u_PreviousView; + vec4 u_Denoise; // x: wavelet step, y: minimum history weight, z: trace writes demodulated light, w: final remodulates }; const float PI=3.141592653589793; uint randomState; @@ -197,37 +205,41 @@ vec3 directLight(Surface s,vec3 view) { for(int index=0;index=int(u_Counts.w)) break; float distance=u_Trace.x; vec3 bary; int hit=intersectScene(origin,direction,distance,bary,false); if(hit<0) { radiance+=throughput*sky(direction); break; } Surface s=surfaceAt(hit,bary,origin,direction,distance); radiance+=throughput*(s.emission+directLight(s,-direction)); - vec3 view=-direction; - vec3 f0=mix(vec3(0.04),s.color,s.metal); - float chance=clamp(max(f0.r,max(f0.g,f0.b)),0.1,0.9); vec3 nextDirection; - float pdf; - if(randomValue()=2) { float survive=clamp(max(throughput.r,max(throughput.g,throughput.b)),0.05,0.95); if(randomValue()>survive) break; @@ -235,57 +247,177 @@ vec3 path(vec3 origin,vec3 direction) { } origin=s.position+s.geometric*u_Trace.y*2.0; direction=nextDirection; } - return clamp(radiance,vec3(0),vec3(u_Trace.z)); + return radiance; } -[[main]] -void path_fragment() { - if(u_Frame.y>1.5) { - // Geometry-aware reconstruction; filtering happens after, never inside, static accumulation. - vec3 n=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0).xyz; - vec3 p=textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).xyz; - vec2 size=vec2(textureSize(sampler2D(u_History,u_Nearest),0)); - vec2 center=floor(v_UV*size-0.5),fraction=fract(v_UV*size-0.5); - vec3 sum=vec3(0); float weightSum=0.0; - for(int y=0;y<2;++y) for(int x=0;x<2;++x) { - vec2 uv=clamp((center+vec2(x,y)+0.5)/size,vec2(0.0),vec2(1.0)); - vec3 otherN=textureLod(sampler2D(u_Normal,u_Nearest),uv,0).xyz; - vec3 otherP=textureLod(sampler2D(u_Position,u_Nearest),uv,0).xyz; - float weight=(x==0?1.0-fraction.x:fraction.x)*(y==0?1.0-fraction.y:fraction.y); - bool surface=dot(n,n)>0.01,otherSurface=dot(otherN,otherN)>0.01; - if(surface!=otherSurface) weight=0.0; - if(surface && otherSurface) weight*=pow(max(dot(normalize(n),normalize(otherN)),0.0),16.0)*exp(-abs(p.z-otherP.z)/max(0.02,p.z*0.01)); - sum+=textureLod(sampler2D(u_History,u_Nearest),uv,0).rgb*weight; weightSum+=weight; +// The raster G-buffer's surface at `uv`: full model detail, independent of the traced LOD geometry. +bool gbufferSurface(vec2 uv,out Surface s,out vec3 view) { + if(u_Hybrid.x<0.5) return false; + vec4 normalRoughness=textureLod(sampler2D(u_Normal,u_Nearest),uv,0); + if(dot(normalRoughness.xyz,normalRoughness.xyz)<0.01) return false; + vec4 positionMetallic=textureLod(sampler2D(u_Position,u_Nearest),uv,0); + s.position=(u_InverseView*vec4(positionMetallic.xyz,1)).xyz; + s.normal=normalize(mat3(u_InverseView)*normalRoughness.xyz); + view=normalize(u_InverseView[3].xyz-s.position); + if(dot(s.normal,view)<0.0) s.normal=-s.normal; + // The G-buffer has no geometric normal; the shading normal also offsets continuation rays. + s.geometric=s.normal; + s.color=textureLod(sampler2D(u_Albedo,u_Nearest),uv,0).rgb; + s.emission=max(textureLod(sampler2D(u_Emission,u_Nearest),uv,0).rgb,vec3(0)); + s.roughness=clamp(normalRoughness.w,0.04,1.0); s.metal=clamp(positionMetallic.w,0.0,1.0); s.receives=true; + return true; +} +bool hasSurface(vec2 uv) { + vec3 n=textureLod(sampler2D(u_Normal,u_Nearest),uv,0).xyz; + return dot(n,n)>0.01; +} +// Base colour that divides lighting before filtering, so texture detail is never blurred. 1 off surfaces. +vec3 demodulation(vec2 uv) { + if(u_Hybrid.x<0.5 || !hasSurface(uv)) return vec3(1); + return max(textureLod(sampler2D(u_Albedo,u_Nearest),uv,0).rgb,vec3(0.03)); +} +vec3 emissionAt(vec2 uv) { + if(u_Hybrid.x<0.5 || !hasSurface(uv)) return vec3(0); + return max(textureLod(sampler2D(u_Emission,u_Nearest),uv,0).rgb,vec3(0)); +} +float luminance(vec3 c) { return dot(c,vec3(0.2126,0.7152,0.0722)); } +// Pass 1: reproject the previous frame's accumulated light (input B) through this pixel's world position, reject it +// where the previous guide (input C: world normal and camera distance) disagrees, and blend the new sample (input A). +vec4 temporalPass() { + vec3 current=textureLod(sampler2D(u_History,u_Nearest),v_UV,0).rgb; + vec4 normal=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0); + if(dot(normal.xyz,normal.xyz)<0.01 || u_Trace.w<0.5) return vec4(current,1); + vec3 viewPosition=textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).xyz; + vec3 world=(u_InverseView*vec4(viewPosition,1)).xyz; + vec3 worldNormal=normalize(mat3(u_InverseView)*normal.xyz); + vec4 clip=u_PreviousViewProjection*vec4(world,1); + vec4 history=vec4(0); + if(clip.w>0.0001) { + vec2 uv=clip.xy/clip.w*vec2(0.5,-0.5)+0.5; + if(all(greaterThanEqual(uv,vec2(0))) && all(lessThanEqual(uv,vec2(1)))) { + vec4 guide=textureLod(sampler2D(u_InputC,u_Nearest),uv,0); + float expected=length((u_PreviousView*vec4(world,1)).xyz); + if(dot(guide.xyz,worldNormal)>0.9 && abs(guide.w-expected)<0.05*expected+0.02) + history=textureLod(sampler2D(u_InputB,u_Linear),uv,0); } - vec3 fallback=textureLod(sampler2D(u_History,u_Nearest),v_UV,0).rgb; - o_Color=vec4(weightSum>0.00001?sum/weightSum:fallback,1); return; } - if(u_Frame.y>0.5) { - vec4 normal=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0); - vec3 position=textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).xyz; - vec3 center=textureLod(sampler2D(u_History,u_Nearest),v_UV,0).rgb; - if(dot(normal.xyz,normal.xyz)<0.01 || normal.w<0.15) { o_Color=vec4(center,1); return; } - vec2 size=vec2(textureSize(sampler2D(u_History,u_Nearest),0)); - vec3 sum=vec3(0); float weights=0.0; - for(int y=-2;y<=2;++y) for(int x=-2;x<=2;++x) { - vec2 uv=clamp(v_UV+vec2(x,y)/size,vec2(0),vec2(1)); - vec4 otherNormal=textureLod(sampler2D(u_Normal,u_Nearest),uv,0); - vec3 otherPosition=textureLod(sampler2D(u_Position,u_Nearest),uv,0).xyz; - if(dot(otherNormal.xyz,otherNormal.xyz)<0.01) continue; - vec3 value=textureLod(sampler2D(u_History,u_Nearest),uv,0).rgb; - float weight=exp(-float(x*x+y*y)*0.25)*pow(max(dot(normalize(normal.xyz),normalize(otherNormal.xyz)),0.0),32.0); - weight*=exp(-abs(position.z-otherPosition.z)/max(0.015,position.z*0.005)); - weight*=exp(-abs(normal.w-otherNormal.w)*12.0); - // Bounded anti-firefly weighting; raw sample accumulation remains unfiltered. - weight/=sqrt(1.0+dot(value,vec3(0.2126,0.7152,0.0722))); - sum+=value*weight; weights+=weight; + if(history.a>0.0) { + // Clamp history to the current neighbourhood (mean +- 2 sigma per channel): a light that switches off or + // turns away is followed at once, while ordinary sample noise keeps the range wide enough to converge. + vec2 texel=1.0/vec2(textureSize(sampler2D(u_History,u_Nearest),0)); + vec3 mean=vec3(0),square=vec3(0); + for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { + vec3 value=textureLod(sampler2D(u_History,u_Nearest),clamp(v_UV+vec2(x,y)*texel,vec2(0),vec2(1)),0).rgb; + mean+=value; square+=value*value; } - o_Color=vec4(weights>0.00001?sum/weights:center,1); return; + mean/=9.0; + vec3 sigma=sqrt(max(square/9.0-mean*mean,vec3(0))); + history.rgb=clamp(history.rgb,mean-2.0*sigma,mean+2.0*sigma); + } + float frames=min(history.a+1.0,1024.0); + return vec4(mix(history.rgb,current,max(1.0/frames,u_Denoise.y)),frames); +} +// Pass 2: this frame's guide for the next frame's reprojection. +vec4 guidePass() { + vec4 normal=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0); + if(dot(normal.xyz,normal.xyz)<0.01) return vec4(0); + vec3 viewPosition=textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).xyz; + return vec4(normalize(mat3(u_InverseView)*normal.xyz),length(viewPosition)); +} +// Pass 3: one edge-aware a-trous wavelet iteration with spacing u_Denoise.x, guided by normal, depth and the +// local luminance variance, so flat noisy regions smooth while edges and texture-independent detail stay. +vec4 waveletPass() { + vec4 center=textureLod(sampler2D(u_History,u_Nearest),v_UV,0); + vec4 normal=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0); + if(dot(normal.xyz,normal.xyz)<0.01) return center; + vec2 texel=1.0/vec2(textureSize(sampler2D(u_History,u_Nearest),0)); + float mean=0.0,square=0.0; + for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { + float l=luminance(textureLod(sampler2D(u_History,u_Nearest),clamp(v_UV+vec2(x,y)*texel,vec2(0),vec2(1)),0).rgb); + mean+=l; square+=l*l; } + mean/=9.0; + float sigma=4.0*sqrt(max(square/9.0-mean*mean,0.0))+0.0001; + float depth=length(textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).xyz); + float centerLuminance=luminance(center.rgb); + const float kernel[3]=float[3](0.375,0.25,0.0625); + vec3 sum=vec3(0); float weights=0.0; + for(int y=-2;y<=2;++y) for(int x=-2;x<=2;++x) { + vec2 uv=v_UV+vec2(x,y)*texel*u_Denoise.x; + if(any(lessThan(uv,vec2(0))) || any(greaterThan(uv,vec2(1)))) continue; + vec4 otherNormal=textureLod(sampler2D(u_Normal,u_Nearest),uv,0); + if(dot(otherNormal.xyz,otherNormal.xyz)<0.01) continue; + vec3 value=textureLod(sampler2D(u_History,u_Nearest),uv,0).rgb; + float otherDepth=length(textureLod(sampler2D(u_Position,u_Nearest),uv,0).xyz); + float weight=kernel[abs(x)]*kernel[abs(y)]; + weight*=pow(max(dot(normalize(normal.xyz),normalize(otherNormal.xyz)),0.0),128.0); + weight*=exp(-abs(depth-otherDepth)/(0.01*depth*u_Denoise.x+0.001)); + weight*=exp(-abs(centerLuminance-luminance(value))/sigma); + sum+=value*weight; weights+=weight; + } + return vec4(weights>0.00001?sum/weights:center.rgb,center.a); +} +// Pass 4: geometry-aware reconstruction at output resolution, then remodulation with the full-resolution surface. +vec4 finalPass() { + vec3 n=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0).xyz; + vec3 p=textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).xyz; + vec2 size=vec2(textureSize(sampler2D(u_History,u_Nearest),0)); + vec2 center=floor(v_UV*size-0.5),fraction=fract(v_UV*size-0.5); + vec3 sum=vec3(0); float weightSum=0.0; + for(int y=0;y<2;++y) for(int x=0;x<2;++x) { + vec2 uv=clamp((center+vec2(x,y)+0.5)/size,vec2(0.0),vec2(1.0)); + vec3 otherN=textureLod(sampler2D(u_Normal,u_Nearest),uv,0).xyz; + vec3 otherP=textureLod(sampler2D(u_Position,u_Nearest),uv,0).xyz; + float weight=(x==0?1.0-fraction.x:fraction.x)*(y==0?1.0-fraction.y:fraction.y); + bool surface=dot(n,n)>0.01,otherSurface=dot(otherN,otherN)>0.01; + if(surface!=otherSurface) weight=0.0; + if(surface && otherSurface) weight*=pow(max(dot(normalize(n),normalize(otherN)),0.0),16.0)*exp(-abs(p.z-otherP.z)/max(0.02,p.z*0.01)); + sum+=textureLod(sampler2D(u_History,u_Nearest),uv,0).rgb*weight; weightSum+=weight; + } + vec3 result=weightSum>0.00001?sum/weightSum:textureLod(sampler2D(u_History,u_Nearest),v_UV,0).rgb; + if(u_Denoise.w>0.5) result=result*demodulation(v_UV)+emissionAt(v_UV); + return vec4(result,1); +} +// Pass 5: Ray Reconstruction guides; u_Denoise.x selects diffuse albedo (0), specular albedo (1) or world normal +// with roughness (2). +vec4 rayReconstructionGuidePass() { + vec4 normal=textureLod(sampler2D(u_Normal,u_Nearest),v_UV,0); + if(dot(normal.xyz,normal.xyz)<0.01) return vec4(0); + if(u_Denoise.x>1.5) return vec4(normalize(mat3(u_InverseView)*normal.xyz),normal.w); + vec3 base=textureLod(sampler2D(u_Albedo,u_Nearest),v_UV,0).rgb; + float metal=textureLod(sampler2D(u_Position,u_Nearest),v_UV,0).w; + return vec4(u_Denoise.x<0.5 ? base*(1.0-metal) : mix(vec3(0.04),base,metal),1); +} +[[main]] +void path_fragment() { + // Pass 0 traces; 1 temporal, 2 guide and 3 wavelet denoise; 4 reconstructs the output; 5 writes RR guides. + if(u_Frame.y>4.5) { o_Color=rayReconstructionGuidePass(); return; } + if(u_Frame.y>3.5) { o_Color=finalPass(); return; } + if(u_Frame.y>2.5) { o_Color=waveletPass(); return; } + if(u_Frame.y>1.5) { o_Color=guidePass(); return; } + if(u_Frame.y>0.5) { o_Color=temporalPass(); return; } uvec2 pixel=uvec2(gl_FragCoord.xy); randomState=pixel.x*1973u+pixel.y*9277u+uint(u_Frame.x)*26699u+911u; vec3 color=vec3(0); + Surface first; vec3 firstView; + bool hybrid=gbufferSurface(v_UV,first,firstView); + if(u_Hybrid.x>0.5 && !hybrid) { + // The raster pass drew nothing here: the sky. Never trace it, so path-tracing-only proxies stay invisible. + vec2 ndc=v_UV*vec2(2,-2)+vec2(-1,1); + vec4 nearPoint=u_InverseProjection*vec4(ndc,0,1),farPoint=u_InverseProjection*vec4(ndc,1,1); + vec3 direction=normalize(mat3(u_InverseView)*(farPoint.xyz/farPoint.w-nearPoint.xyz/nearPoint.w)); + o_Color=vec4(clamp(sky(direction),vec3(0),vec3(u_Trace.z)),1); return; + } for(int sampleIndex=0;sampleIndex<8;++sampleIndex) { if(sampleIndex>=int(u_Counts.z)) break; + if(hybrid) { + vec3 throughput=vec3(1),nextDirection; + vec3 lighting=directLight(first,firstView); + if(int(u_Counts.w)>1 && scatter(first,firstView,throughput,nextDirection)) + lighting+=throughput*path(first.position+first.geometric*u_Trace.y*2.0,nextDirection,1); + // Clamp only the light: emission is added after, so demodulation removes it exactly. + color+=clamp(lighting,vec3(0),vec3(u_Trace.z))+first.emission; + continue; + } vec2 size=vec2(textureSize(sampler2D(u_History,u_Nearest),0)); vec2 uv=v_UV+(vec2(randomValue(),randomValue())-0.5)/size; vec2 ndc=uv*vec2(2,-2)+vec2(-1,1); @@ -293,9 +425,14 @@ void path_fragment() { vec3 viewOrigin=nearPoint.xyz/nearPoint.w; vec3 direction=normalize(mat3(u_InverseView)*(farPoint.xyz/farPoint.w-viewOrigin)); vec3 origin=(u_InverseView*vec4(viewOrigin,1)).xyz; - color+=path(origin,direction); + color+=clamp(path(origin,direction,0),vec3(0),vec3(u_Trace.z)); } color/=u_Counts.z; + if(u_Denoise.z>0.5) { + // Denoisers filter light without the surface's own colour and emission; the final pass restores both. + o_Color=vec4(max(color-emissionAt(v_UV),vec3(0))/demodulation(v_UV),1); return; + } + // Plain accumulation of a still view (reference mode). if(u_Trace.w>0.0) color=mix(color,textureLod(sampler2D(u_History,u_Nearest),v_UV,0).rgb,u_Trace.w/(u_Trace.w+1.0)); o_Color=vec4(color,1); } diff --git a/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift b/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift index 1a00e7c78..3acaacc15 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Core3DPlugin.swift @@ -148,6 +148,7 @@ public struct Core3DPlugin: Plugin { .insertResource(RenderPipelines(configurator: LocalShadow3DPipeline())) .insertResource(ExtractedMesh3DSources()) .insertResource(VisibleMesh3DLists()) + .insertResource(ShadowCasterRegistry3D()) .insertResource(GPUVisibility3DState()) .insertResource(Meshlet3DState()) .insertResource(MeshletMap3DResources()) diff --git a/Sources/AdaCorePipelines/CorePipelines/GPUShadowCull3D.swift b/Sources/AdaCorePipelines/CorePipelines/GPUShadowCull3D.swift new file mode 100644 index 000000000..3ae58262a --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/GPUShadowCull3D.swift @@ -0,0 +1,196 @@ +import AdaECS +@_spi(Internal) import AdaRender +import AdaUtils +import Math + +/// GPU culling results of one frame's shadow views: per caster, per meshlet cluster, or both. +struct GPUShadowCullResult: Sendable { + var casters: GPUShadowCullOutput? + var clusters: GPUShadowClusterCullOutput? +} + +/// Per-camera GPU shadow culling owned by one shadow render node. +struct GPUShadowCull3D: Sendable { + private var processors: [Entity.ID: any GPUShadowCullProcessor] = [:] + private var clusterProcessors: [Entity.ID: any GPUShadowClusterCullProcessor] = [:] + private var unavailable: Set = [] + + mutating func retainViews(_ active: [Entity.ID]) { + for key in processors.keys where !active.contains(key) { processors.removeValue(forKey: key) } + for key in clusterProcessors.keys where !active.contains(key) { clusterProcessors.removeValue(forKey: key) } + unavailable = unavailable.filter { active.contains($0) } + } + + /// Whether the GPU path will draw this camera's registry casters; otherwise every caster takes the CPU path. + /// `viewCount` is the most views one cull may contain; outputs larger than a baseline storage binding + /// (128 MiB) keep the CPU path. + /// Cluster casters need their own processor; argument outputs above ``GPUShadowClusterCullLimits`` keep the CPU path. + mutating func isAvailable(camera: Entity.ID, registry: ShadowCasterRegistry3D, viewCount: Int, device: RenderDevice) -> Bool { + let limit = 128 << 20 + let views = max(viewCount, 1) + guard registry.isEnabled, registry.hasCasters || registry.hasClusterCasters, !unavailable.contains(camera) else { return false } + if registry.hasCasters { + guard registry.isInputValid, + views * registry.input.casters.count * registry.input.instanceStride <= limit, + views * registry.partCount * MemoryLayout.stride <= limit + else { return false } + } + if registry.hasClusterCasters { + guard registry.isClusterInputValid, + views * registry.clusterInput.clusters.count * MemoryLayout.stride <= GPUShadowClusterCullLimits.argumentBytes + else { return false } + } + if registry.hasCasters, processors[camera] == nil { + guard let processor = device.createGPUShadowCullProcessor() else { + unavailable.insert(camera) + return false + } + processors[camera] = processor + } + if registry.hasClusterCasters, clusterProcessors[camera] == nil { + guard let processor = device.createGPUShadowClusterCullProcessor() else { + unavailable.insert(camera) + return false + } + clusterProcessors[camera] = processor + } + return true + } + + /// Encodes culling for `views` (in output order) before the shadow render pass on `command`. + mutating func cull( + camera: Entity.ID, + registry: inout ShadowCasterRegistry3D, + views: [GPUShadowCullView], + command: CommandBuffer, + device: RenderDevice + ) -> GPUShadowCullResult? { + guard !views.isEmpty else { return nil } + registry.flushCopies(on: command) + // Every GPU-path caster must be drawn: if either cull cannot be encoded, the caller takes the CPU path. + var result = GPUShadowCullResult() + if registry.hasCasters { + guard let processor = processors[camera], + let output = processor.encode(on: command, input: registry.input, views: views, firstInstance: device.supportsMultiDrawIndirect) + else { return nil } + result.casters = output + } + if registry.hasClusterCasters { + guard let processor = clusterProcessors[camera], + let output = processor.encode(on: command, input: registry.clusterInput, views: views) + else { return nil } + result.clusters = output + } + return result + } + + /// Draws the registry casters culled for output view `view`. The view uniform must already be bound. + /// Returns the number of indirect draw calls issued. + @discardableResult + static func draw( + registry: ShadowCasterRegistry3D, + output: GPUShadowCullResult, + view: Int, + pass: RenderCommandEncoder, + pipelines: inout RenderPipelines

, + device: RenderDevice + ) -> Int where P.Configuration == Shadow3DConfiguration { + var calls = 0 + if let casters = output.casters { + calls += drawCasters(registry: registry, output: casters, view: view, pass: pass, pipelines: &pipelines, device: device) + } + if let clusters = output.clusters { + calls += drawClusters(registry: registry, output: clusters, view: view, pass: pass, pipelines: &pipelines, device: device) + } + return calls + } + + /// Draws the surviving clusters of each cluster caster in output view `view`: one count-driven multi-draw per part. + private static func drawClusters( + registry: ShadowCasterRegistry3D, + output: GPUShadowClusterCullOutput, + view: Int, + pass: RenderCommandEncoder, + pipelines: inout RenderPipelines

, + device: RenderDevice + ) -> Int where P.Configuration == Shadow3DConfiguration { + // The output describes the input it culled; a registry that changed since would misalign its parts. + guard view < output.viewCount, output.input.revision == registry.clusterInput.revision, + registry.clusterParts.count == output.input.segments.count + else { return 0 } + var instances = BufferData(elements: []) + instances.buffer = output.instances + var calls = 0 + // Parts share pipelines and materials: rebind only what changes between consecutive parts. + var boundDescriptor: VertexDescriptor? + var boundMaterial: ObjectIdentifier? + for (index, part) in registry.clusterParts.enumerated() { + if boundDescriptor != part.descriptor { + pass.setRenderPipelineState(pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.descriptor, morph: false), device: device)) + pass.setVertexBuffer(instances, offset: 0, slot: 3) + if let defaults = registry.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } + boundDescriptor = part.descriptor + } + if boundMaterial != ObjectIdentifier(part.material) { + pass.setResourceSet(part.resources, index: 0) + boundMaterial = ObjectIdentifier(part.material) + } + pass.setVertexBuffer(part.vertex, offset: 0, slot: 0) + pass.setIndexBuffer(part.index, offset: 0) + if pass.multiDrawIndexedIndirect( + arguments: output.arguments, + offset: output.argumentOffset(view: view, part: index), + maxDrawCount: output.input.clusterCount(part: index), + drawCountBuffer: output.counts, + drawCountOffset: output.countOffset(view: view, part: index) + ) { + calls += 1 + } + } + return calls + } + + private static func drawCasters( + registry: ShadowCasterRegistry3D, + output: GPUShadowCullOutput, + view: Int, + pass: RenderCommandEncoder, + pipelines: inout RenderPipelines

, + device: RenderDevice + ) -> Int where P.Configuration == Shadow3DConfiguration { + guard view < output.viewCount, output.partCount == registry.partCount else { return 0 } + var instances = BufferData(elements: []) + instances.buffer = output.instances + let multiDraw = device.supportsMultiDrawIndirect + var calls = 0 + for group in registry.groups { + guard let buffers = registry.buffers(for: group) else { continue } + pass.setRenderPipelineState(pipelines.pipeline(for: Shadow3DConfiguration(vertex: group.descriptor, morph: false), device: device)) + pass.setResourceSet(group.resources, index: 0) + pass.setVertexBuffer(buffers.vertex, offset: 0, slot: 0) + pass.setIndexBuffer(buffers.index, offset: 0) + if let defaults = registry.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } + if multiDraw { + pass.setVertexBuffer(instances, offset: 0, slot: 3) + if pass.multiDrawIndexedIndirect( + arguments: output.arguments, + offset: output.argumentOffset(view: view, part: group.firstPart), + drawCount: group.partCount + ) { + calls += 1 + continue + } + } + // Without multi-draw each part's arguments have firstInstance 0: select its instances by offset. + // With it (if a multi-draw was refused) the arguments already carry firstInstance. + for part in group.firstPart..<(group.firstPart + group.partCount) { + let first = multiDraw ? 0 : output.firstInstance(view: view, segment: registry.input.segments[part]) + pass.setVertexBuffer(instances, offset: first * output.instanceStride, slot: 3) + if pass.drawIndexedIndirect(arguments: output.arguments, offset: output.argumentOffset(view: view, part: part)) { + calls += 1 + } + } + } + return calls + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/Lighting3D.swift b/Sources/AdaCorePipelines/CorePipelines/Lighting3D.swift index 8c173ccb9..aa80c5e71 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Lighting3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Lighting3D.swift @@ -129,6 +129,7 @@ public struct DirectionalShadowViewUniform: Sendable { public struct DirectionalShadow3DScratch: Resource, Sendable { public var view: BufferData var cache = FrameUniformCache3D() + var gpuCull = GPUShadowCull3D() public init() { self.view = BufferData(label: "Directional Shadow View", elements: []) diff --git a/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift b/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift index e1cf57c4a..3ae13978e 100644 --- a/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/LocalLighting3D.swift @@ -177,6 +177,40 @@ public enum LocalLighting3DMath { let directions: [Vector3] = light.kind == .point ? [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]] : [light.direction] return directions.map { projection * view(position: light.position, direction: $0) } } + + /// Conservative world bounds of what one shadow face can see. A point light face covers the 90° pyramid + /// along its axis, which lies inside the half-box towards that axis; a spot face lies inside the light's + /// range sphere. A receiver can only sample a face whose volume contains it. + public static func shadowFaceBounds(_ light: ExtractedLocalLight3D, face: Int) -> AABB { + let range = finiteRenderValue(light.range, fallback: 10, range: 0.1...500) + let position = light.position + guard light.kind == .point, (0..<6).contains(face) else { + return AABB(min: position - Vector3(range, range, range), max: position + Vector3(range, range, range)) + } + var lower = position - Vector3(range, range, range) + var upper = position + Vector3(range, range, range) + let positive = face % 2 == 0 + switch face / 2 { + case 0: if positive { lower.x = position.x } else { upper.x = position.x } + case 1: if positive { lower.y = position.y } else { upper.y = position.y } + default: if positive { lower.z = position.z } else { upper.z = position.z } + } + return AABB(min: lower, max: upper) + } + + /// Whether a face can affect what is shaded for a view: its volume reaches the camera frustum, or the + /// surfaces lit by dynamic irradiance (which may lie outside the view). + public static func shadowFaceIsSampled(_ bounds: AABB, viewFrustum: MeshVisibilityFrustum3D, irradianceBounds: AABB?) -> Bool { + if viewFrustum.intersects(bounds) { + return true + } + guard let irradianceBounds else { + return false + } + return bounds.min.x <= irradianceBounds.max.x && bounds.max.x >= irradianceBounds.min.x + && bounds.min.y <= irradianceBounds.max.y && bounds.max.y >= irradianceBounds.min.y + && bounds.min.z <= irradianceBounds.max.z && bounds.max.z >= irradianceBounds.min.z + } } /// Stable slots within a camera's bounded atlas. Changing rank doesn't move retained winners. @@ -244,9 +278,13 @@ struct LocalShadow3DViews: Resource { var faceMatrixDigests: [Int: UInt64] = [:] /// Fingerprint of the shadow casters when the faces were last prepared, or `nil` when it was not reusable. var sceneDigest: UInt64? + /// Shadow caster registry revision the faces were last drawn with, when they used the GPU path. + var registryRevision: UInt64? /// Faces drawn and faces kept from an earlier frame by the last pass. var renderedFaces = 0 var reusedFaces = 0 + /// Faces no visible or irradiance-lit surface could sample, skipped and marked stale by the last pass. + var culledFaces = 0 } var entries: [Entity.ID: Entry] = [:] } diff --git a/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift b/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift index cee3ee939..b378188ab 100644 --- a/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/MeshVisibility3D.swift @@ -66,6 +66,8 @@ public struct Mesh3DRenderSource: Sendable { public var forceVisible: Bool public var castShadows: Bool public var receiveShadows: Bool + /// The resolved ``PathTracingGeometry3D`` of the mesh entity or its nearest ancestor. + public var pathTracing: PathTracingGeometry3D? public var skinningBuffer: (any UniformBuffer)? /// First joint of this instance inside a packed GPU palette. public var skinningOffset: Int @@ -119,8 +121,17 @@ public struct Mesh3DRenderSource: Sendable { } public struct ExtractedMesh3DSources: Resource { + /// Meshes drawn by raster passes, including ``PathTracingGeometry3D/rasterOnly`` ones. public var meshes: [Mesh3DRenderSource] = [] + /// ``PathTracingGeometry3D/pathTracingOnly`` proxies, intersected by the path tracer only. + public var pathTracingProxies: [Mesh3DRenderSource] = [] public init() {} + + /// The meshes the path tracer intersects: raster meshes that are not raster-only, plus the proxies. + public var pathTracedMeshes: [Mesh3DRenderSource] { + guard meshes.contains(where: { $0.pathTracing == PathTracingGeometry3D.rasterOnly }) || !pathTracingProxies.isEmpty else { return meshes } + return meshes.filter { $0.pathTracing != PathTracingGeometry3D.rasterOnly } + pathTracingProxies + } } public struct Visibility3DCount: Codable, Sendable { diff --git a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift index dfa5519a6..3fd335d48 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PBR3DUniforms.swift @@ -180,7 +180,8 @@ public struct PBR3DUniforms: Resource { } private var entries: [Key: Entry] = [:] private var materialBuffers: [ObjectIdentifier: MaterialEntry] = [:] - private var frame = 0 + /// Advances once per extracted frame. + private(set) var frame = 0 public init() {} diff --git a/Sources/AdaCorePipelines/CorePipelines/PathTracing3D.swift b/Sources/AdaCorePipelines/CorePipelines/PathTracing3D.swift index 352827ead..3e859bcae 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PathTracing3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PathTracing3D.swift @@ -1,5 +1,18 @@ import Foundation +/// How a path-traced view removes the noise of its few samples per pixel. +public enum PathTracingDenoiser: String, Codable, Sendable, CaseIterable { + /// DLSS Ray Reconstruction where the device supports it and the camera uses DLSS temporal upscaling; + /// the portable denoiser everywhere else. + case automatic + /// DLSS Ray Reconstruction; falls back to ``portable`` with a status reason when unavailable. + case rayReconstruction + /// Temporal reprojection and an edge-aware wavelet filter, on every backend. + case portable + /// Plain accumulation of a still view, which any camera, light or scene change restarts. For reference renders. + case off +} + /// Experimental world-space path tracing on the portable GPU backend. /// Intersections run in a shader BVH; this does not claim hardware RT acceleration. public struct PathTracing3D: Codable, Equatable, Sendable { @@ -12,9 +25,15 @@ public struct PathTracing3D: Codable, Equatable, Sendable { public var textureTileSize: Int = 128 public var maximumDistance: Float = 200 public var maximumRadiance: Float = 64 - /// Static views accumulate samples; changes to camera, geometry, materials or lights reset history. + /// Frames accumulated by the `.off` denoiser, whose history any camera, geometry, material or light change restarts. + /// The portable denoiser's history is bounded by ``minimumHistoryWeight`` instead. public var historyLimit: Int = 32 public var rayBias: Float = 0.002 + /// Denoiser selection. See ``PathTracingDenoiser``. + public var denoiser: PathTracingDenoiser = .automatic + /// Smallest weight a new frame gets in the portable denoiser's history. Smaller values converge to less noise + /// in still views; larger ones follow moving lights faster. + public var minimumHistoryWeight: Float = 1.0 / 32 public init(isEnabled: Bool = false, samplesPerPixel: Int = 1, maximumBounces: Int = 3, resolutionScale: Float = 0.5, historyLimit: Int = 32) { @@ -35,11 +54,13 @@ public struct PathTracing3D: Codable, Equatable, Sendable { result.maximumRadiance = finiteRenderValue(maximumRadiance, fallback: 64, range: 1...10000) result.rayBias = finiteRenderValue(rayBias, fallback: 0.002, range: 0.0001...0.1) result.historyLimit = min(max(historyLimit, 1), 1024) + result.minimumHistoryWeight = finiteRenderValue(minimumHistoryWeight, fallback: 1.0 / 32, range: 0.005...1) return result } private enum CodingKeys: CodingKey { case isEnabled, samplesPerPixel, maximumBounces, resolutionScale, textureTileSize, maximumDistance, maximumRadiance, historyLimit, rayBias + case denoiser, minimumHistoryWeight } public init(from decoder: Decoder) throws { @@ -53,5 +74,7 @@ public struct PathTracing3D: Codable, Equatable, Sendable { maximumRadiance = try c.decodeIfPresent(Float.self, forKey: .maximumRadiance) ?? 64 historyLimit = try c.decodeIfPresent(Int.self, forKey: .historyLimit) ?? 32 rayBias = try c.decodeIfPresent(Float.self, forKey: .rayBias) ?? 0.002 + denoiser = try c.decodeIfPresent(PathTracingDenoiser.self, forKey: .denoiser) ?? .automatic + minimumHistoryWeight = try c.decodeIfPresent(Float.self, forKey: .minimumHistoryWeight) ?? 1.0 / 32 } } diff --git a/Sources/AdaCorePipelines/CorePipelines/PathTracingGeometry3D.swift b/Sources/AdaCorePipelines/CorePipelines/PathTracingGeometry3D.swift new file mode 100644 index 000000000..246cd6e94 --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/PathTracingGeometry3D.swift @@ -0,0 +1,20 @@ +import AdaECS + +/// Chooses which geometry the path tracer traces, for an entity and its descendants. +/// +/// Path tracing takes its first hit from the raster G-buffer, so a scene can raster a detailed model while +/// the tracer intersects a lighter proxy for bounces and shadows: +/// +/// ```swift +/// detailed.components += PathTracingGeometry3D.rasterOnly +/// proxy.components += PathTracingGeometry3D.pathTracingOnly +/// ``` +/// +/// Without the component a mesh is both drawn and traced. A descendant's own value overrides its ancestors'. +@Component +public enum PathTracingGeometry3D: Codable, Sendable, Equatable { + /// Drawn by raster passes; the path tracer does not intersect it. + case rasterOnly + /// Intersected by the path tracer only; raster passes, shadows and visibility never see it. + case pathTracingOnly +} diff --git a/Sources/AdaCorePipelines/CorePipelines/PrepareEnvironment3DTexturesSystem.swift b/Sources/AdaCorePipelines/CorePipelines/PrepareEnvironment3DTexturesSystem.swift index 76899fe0c..5d6659e43 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PrepareEnvironment3DTexturesSystem.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PrepareEnvironment3DTexturesSystem.swift @@ -76,14 +76,19 @@ public struct PrepareEnvironment3DTexturesSystem { let environment = environments.environments[source.entityId] ?? Environment3D() activeViews.insert(source.entityId) colorCorrection.prepare(environment.postProcess.colorGrading, view: source.entityId) - let hybrid = environment.imageBasedLighting?.asset.asset != nil && environment.screenSpaceReflection.isEnabled - && environment.debugView == .none && device.renderDevice.supportsHybridReflections + // Path tracing takes its first hit from the G-buffer. Its surface needs both optional outputs, which + // screen-space reflections and GI do not use while tracing. + let pathSurface = environment.pathTracing.sanitized.isEnabled && environment.debugView == .none + && device.renderDevice.supportsHybridReflections && device.renderDevice.supportsLightingSurfaceBuffer + target.pathTracingSurfaceActive = pathSurface + let hybrid = pathSurface || (environment.imageBasedLighting?.asset.asset != nil && environment.screenSpaceReflection.isEnabled + && environment.debugView == .none && device.renderDevice.supportsHybridReflections) if hybrid && target.reflectionResponse3DTexture?.size != size { target.reflectionResponse3DTexture = RenderTexture(size: size, scaleFactor: scale, format: .rgba_16f, debugLabel: "3D Opaque Reflection Response") } else if !hybrid { target.reflectionResponse3DTexture = nil } - if environment.screenSpaceGI.isEnabled && device.renderDevice.supportsLightingSurfaceBuffer && environment.debugView == .none { + if pathSurface || (environment.screenSpaceGI.isEnabled && device.renderDevice.supportsLightingSurfaceBuffer && environment.debugView == .none) { if target.diffuseSurface3DTexture?.size != size { target.diffuseSurface3DTexture = RenderTexture(size: size, scaleFactor: scale, format: .rgba8, debugLabel: "3D Diffuse Surface Response") } diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift index 01c1a1edd..494a44839 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DirectionalShadow3DRenderNode.swift @@ -76,6 +76,9 @@ public struct DirectionalShadow3DRenderNode: RenderNode { @ResMut private var scratch + @ResMut + private var registry + @ResMut> private var pipelines @@ -97,6 +100,7 @@ public struct DirectionalShadow3DRenderNode: RenderNode { _materialUniforms.update(from: world) _shadow.update(from: world) _scratch.update(from: world) + _registry.update(from: world) _pipelines.update(from: world) _renderDevice.update(from: world) let activeViews: [Entity.ID] @@ -109,6 +113,7 @@ public struct DirectionalShadow3DRenderNode: RenderNode { activeViews = extracted } scratch.cache.retainViews(activeViews) + scratch.gpuCull.retainViews(activeViews) } public func execute(context: inout Context, renderContext: RenderContext) async throws -> [RenderSlotValue] { @@ -166,6 +171,27 @@ public struct DirectionalShadow3DRenderNode: RenderNode { } let commandBuffer = renderContext.commandQueue.makeCommandBuffer() commandBuffer.label = "Directional Shadow 3D Pass" + let visibility = environments.environments[source.entityId]?.meshVisibility ?? MeshVisibilitySettings3D() + // Static casters are culled for every cascade in one GPU pass; the rest keep the CPU lists. + registry.update( + sources: candidates.meshes, + materials: &_materialUniforms.wrappedValue, + identityPalette: skinningUniforms.identityBuffer, + device: renderDevice.renderDevice + ) + var culled: GPUShadowCullResult? + if scratch.gpuCull.isAvailable(camera: source.entityId, registry: registry, viewCount: cascades.count, device: renderDevice.renderDevice) { + culled = scratch.gpuCull.cull( + camera: source.entityId, + registry: &_registry.wrappedValue, + views: cascades.map { GPUShadowCullView(viewProjection: $0.viewProjection, culls: visibility.frustumCulling) }, + command: commandBuffer, + device: renderDevice.renderDevice + ) + } + // Pool copies are encoded even when this camera does not cull on the GPU, so they never accumulate. + registry.flushCopies(on: commandBuffer) + let cpuSources = culled == nil ? candidates.meshes : registry.cpuSources let pass = commandBuffer.beginRenderPass( RenderPassDescriptor( label: "Directional Shadow 3D Pass", @@ -184,25 +210,28 @@ public struct DirectionalShadow3DRenderNode: RenderNode { ) ) let resolution = Float(tileResolution) + // Cascades draw overlapping batch sets: resolve each batch's draw state once. + var batches = ShadowBatchEncoder3D(requiresMaterialBuffer: false) for cascadeIndex in cascades.indices { pass.setViewport(Rect(x: Float(cascadeIndex) * resolution, y: 0, width: resolution, height: resolution)) let preparationSpan = AdaTrace.startSpan("Render3D.ShadowPrepare") lists.prepare( - sources: candidates.meshes, + sources: cpuSources, view: source.entityId, pass: cascadeIndex, viewMatrix: viewUniform.viewMatrix, projection: viewUniform.projectionMatrix, cullingProjection: cascades[cascadeIndex].viewProjection, - settings: environments.environments[source.entityId]?.meshVisibility ?? MeshVisibilitySettings3D(), + settings: visibility, device: renderDevice.renderDevice, materials: &_materialUniforms.wrappedValue ) preparationSpan.end() let visibleItems = lists.items(view: source.entityId, pass: cascadeIndex) let buffers = lists.buffers(view: source.entityId, pass: cascadeIndex) - statistics.record(lists.count(view: source.entityId, pass: cascadeIndex), view: source.entityId, pass: cascadeIndex) - guard let instances = buffers.instances else { continue } + var count = lists.count(view: source.entityId, pass: cascadeIndex) + if culled != nil { count.drawCalls += registry.partCount + registry.clusterParts.count } + statistics.record(count, view: source.entityId, pass: cascadeIndex) let constants = scratch.cache.write( DirectionalShadowViewUniform( viewProjection: cascades[cascadeIndex].viewProjection, @@ -215,47 +244,26 @@ public struct DirectionalShadow3DRenderNode: RenderNode { pass.setVertexBuffer(constants, offset: 0, slot: GlobalBufferIndex.viewUniform) let encodingSpan = AdaTrace.startSpan("Render3D.ShadowEncode") - for item in visibleItems { - let part = item.mesh.models[item.modelIndex].parts[item.partIndex] - guard item.castShadows, (item.material as? PBRMaterial)?.effectiveAlphaMode != .blend, let batchRange = item.batchRange else { - continue - } - let morphed = item.morphBuffer != nil && part.morphTargets != nil - let pipeline = pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.vertexDescriptor, morph: morphed), device: renderDevice.renderDevice) - guard ShaderGraph3DPipeline.bindShadow(encoder: pass, base: pipeline, material: item.material, device: renderDevice.renderDevice, morph: morphed) else { continue } - pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) - if let material = materialUniforms.buffer(for: item.material) { - let base = (item.material as? PBRMaterial)?.baseColorTexture ?? Texture2D.whiteTexture - pass.setResourceSet(RenderResourceSet(bindings: [ - .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(material, offset: 0)), - .init(binding: 4, shaderStages: .fragment, resource: .texture(base)), - .init(binding: 7, shaderStages: .fragment, resource: .sampler(base.sampler)), - ]), index: 0) - } - if let palette = item.skinningBuffer ?? skinningUniforms.identityBuffer { - pass.setResourceSet(RenderResourceSet(bindings: [ - .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), - ]), index: 0) - } - if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { - pass.setResourceSet(RenderResourceSet(bindings: [ - .init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0)), - .init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture)), - ]), index: 0) - } - if let defaults = buffers.defaults { - pass.setVertexBuffer(defaults, offset: 0, slot: 4) - } - pass.setVertexBuffer( - instances, - offset: Int(batchRange.lowerBound) * MemoryLayout.stride, - slot: 3 + if let instances = buffers.instances { + batches.encode( + items: visibleItems, + instances: instances, + defaults: buffers.defaults, + pass: pass, + pipelines: &_pipelines.wrappedValue, + materials: materialUniforms, + identityPalette: skinningUniforms.identityBuffer, + device: renderDevice.renderDevice ) - pass.setIndexBuffer(part.indexBuffer, offset: 0) - pass.drawIndexed( - indexCount: part.indexCount, - indexBufferOffset: 0, - instanceCount: Int(batchRange.count) + } + if let culled { + GPUShadowCull3D.draw( + registry: registry, + output: culled, + view: cascadeIndex, + pass: pass, + pipelines: &_pipelines.wrappedValue, + device: renderDevice.renderDevice ) } encodingSpan.end() diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift index 9bd7dcd26..4d72243a2 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift @@ -6,6 +6,7 @@ import Math struct LocalShadow3DScratch: Resource { var cache = FrameUniformCache3D() + var gpuCull = GPUShadowCull3D() } struct LocalShadow3DPipeline: RenderPipelineConfigurator { @@ -49,6 +50,7 @@ public struct LocalShadow3DRenderNode: RenderNode { @ResMut private var scratch @ResMut private var uniforms @ResMut private var lists + @ResMut private var registry @ResMut private var materials @Res private var skinning @ResMut> private var pipelines @@ -67,6 +69,7 @@ public struct LocalShadow3DRenderNode: RenderNode { _scratch.update(from: world) _uniforms.update(from: world) _lists.update(from: world) + _registry.update(from: world) _materials.update(from: world) _skinning.update(from: world) _pipelines.update(from: world) @@ -85,10 +88,35 @@ public struct LocalShadow3DRenderNode: RenderNode { } for id in views.entries.keys where !active.contains(id) { views.entries.removeValue(forKey: id) } scratch.cache.retainViews(active) + scratch.gpuCull.retainViews(active) statistics.retainViews(active) uniforms.retainViews(active) } + /// Rebuilds the CPU lists of `faces` from every caster, for a frame whose GPU culling could not be encoded. + private func prepareCPUFallback( + faces: [Int], + sources: [Mesh3DRenderSource], + camera: Entity.ID, + uniform: GlobalViewUniform, + matrices: [Transform3D], + lod: Bool + ) { + for index in faces { + lists.prepare( + sources: sources, + view: camera, + pass: 100 + index, + viewMatrix: uniform.viewMatrix, + projection: uniform.projectionMatrix, + cullingProjection: matrices[index], + settings: MeshVisibilitySettings3D(frustumCulling: true, lod: lod, distanceCulling: false), + device: device.renderDevice, + materials: &_materials.wrappedValue + ) + } + } + public func execute(context: inout Context, renderContext: RenderContext) async throws -> [RenderSlotValue] { guard let view = context.viewEntity, let candidates = context.world.getResource(ExtractedMesh3DSources.self) else { @@ -171,8 +199,41 @@ public struct LocalShadow3DRenderNode: RenderNode { : nil PerformanceStallProbe.end("LocalShadow.SceneDigest", since: digestStart, count: candidates.meshes.count) let sceneUnchanged = sceneDigest != nil && sceneDigest == entry.sceneDigest + // Static casters are culled for every redrawn face in one GPU pass; the rest keep the CPU lists. + registry.update( + sources: candidates.meshes, + materials: &_materials.wrappedValue, + identityPalette: skinning.identityBuffer, + device: device.renderDevice + ) + let usesGPU = scratch.gpuCull.isAvailable( + camera: source.entityId, + registry: registry, + viewCount: entry.allocator.slots.count * 6, + device: device.renderDevice + ) + let cpuSources = usesGPU ? registry.cpuSources : candidates.meshes + // Faces kept from earlier frames must also hold the current GPU-path casters. + let registryRevision: UInt64? = usesGPU ? registry.revision : nil + let registryChanges: [AABB]? + if registryRevision == entry.registryRevision { + registryChanges = [] + } else if let previous = entry.registryRevision, let current = registryRevision, current == previous &+ 1 { + registryChanges = registry.changedBounds + } else { + registryChanges = nil + } + // A face is dirtied by a GPU-path caster that was, or now is, inside it (the CPU frustum test). + func registryDirties(_ matrix: Transform3D) -> Bool { + guard let registryChanges else { return true } + guard !registryChanges.isEmpty else { return false } + let frustum = MeshVisibilityFrustum3D(viewProjection: matrix) + return registryChanges.contains { frustum.intersects($0) } + } let prepareStart = PerformanceStallProbe.begin() var preparedFaceCount = 0 + var culledFaceCount = 0 + let viewFrustum = MeshVisibilityFrustum3D(viewProjection: uniform.viewProjectionMatrix) for light in entry.lights { guard let slot = entry.allocator.slots[light.entity] else { continue } let matrices = LocalLighting3DMath.shadowProjections(light) @@ -183,6 +244,13 @@ public struct LocalShadow3DRenderNode: RenderNode { var matrixDigest = FrameDigest3D() matrixDigest.combine(matrices[face]) matrixDigests[index] = matrixDigest.value + // A moving light changes every face matrix each frame. Faces nothing on screen (or lit by + // dynamic irradiance) can sample are skipped; without a digest they redraw once they matter. + let faceBounds = LocalLighting3DMath.shadowFaceBounds(light, face: face) + guard LocalLighting3DMath.shadowFaceIsSampled(faceBounds, viewFrustum: viewFrustum, irradianceBounds: bounds) else { + culledFaceCount += 1 + continue + } if sceneUnchanged, let kept = entry.faceDigests[index], entry.faceMatrixDigests[index] == matrixDigest.value { digests[index] = kept faces.append(Face(index: index, tile: tile, isClean: true)) @@ -191,7 +259,7 @@ public struct LocalShadow3DRenderNode: RenderNode { // Light-volume culling is independent of the main camera list and its distance cuts. let listStart = PerformanceStallProbe.begin() lists.prepare( - sources: candidates.meshes, + sources: cpuSources, view: source.entityId, pass: 100 + index, viewMatrix: uniform.viewMatrix, @@ -214,15 +282,18 @@ public struct LocalShadow3DRenderNode: RenderNode { digest = combined.value digests[index] = combined.value } - faces.append(Face(index: index, tile: tile, isClean: digest != nil && entry.faceDigests[index] == digest)) + let isClean = digest != nil && entry.faceDigests[index] == digest && !registryDirties(matrices[face]) + faces.append(Face(index: index, tile: tile, isClean: isClean)) } } PerformanceStallProbe.end("LocalShadow.PrepareFaces", since: prepareStart, count: preparedFaceCount) entry.sceneDigest = sceneDigest + entry.registryRevision = registryRevision entry.faceMatrixDigests = matrixDigests entry.faceDigests = digests entry.reusedFaces = faces.filter(\.isClean).count entry.renderedFaces = faces.count - entry.reusedFaces + entry.culledFaces = culledFaceCount guard entry.renderedFaces > 0 else { // Every face already holds what it would draw again. statistics.record(view: source.entityId, scene: _lighting.wrappedValue, entry: entry) @@ -233,6 +304,26 @@ public struct LocalShadow3DRenderNode: RenderNode { let keepsContents = entry.reusedFaces > 0 let command = renderContext.commandQueue.makeCommandBuffer() command.label = "Local Shadows 3D" + let dirtyFaces = faces.filter { !$0.isClean } + registry.flushCopies(on: command) + var culled: GPUShadowCullResult? + if usesGPU { + culled = scratch.gpuCull.cull( + camera: source.entityId, + registry: &_registry.wrappedValue, + views: dirtyFaces.map { GPUShadowCullView(viewProjection: entry.matrices[$0.index], culls: true) }, + command: command, + device: device.renderDevice + ) + if culled == nil { + // The GPU pass could not be encoded: draw every caster from CPU lists instead. + prepareCPUFallback( + faces: dirtyFaces.map(\.index), sources: candidates.meshes, camera: source.entityId, + uniform: uniform, matrices: entry.matrices, lod: environment.meshVisibility.lod + ) + entry.registryRevision = nil + } + } let beginPassStart = PerformanceStallProbe.begin() let pass = command.beginRenderPass( .init( @@ -248,7 +339,9 @@ public struct LocalShadow3DRenderNode: RenderNode { PerformanceStallProbe.end("LocalShadow.BeginPass", since: beginPassStart, count: entry.renderedFaces) let encodeStart = PerformanceStallProbe.begin() var encodedDrawCount = 0 - for face in faces where !face.isClean { + // Faces of one light, and of all lights, draw the same batches: resolve their state once. + var batches = ShadowBatchEncoder3D(requiresMaterialBuffer: true) + for (viewIndex, face) in dirtyFaces.enumerated() { let index = face.index let tile = face.tile pass.setViewport(tile) @@ -258,7 +351,6 @@ public struct LocalShadow3DRenderNode: RenderNode { pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) } let buffers = lists.buffers(view: source.entityId, pass: 100 + index) - guard let instances = buffers.instances else { continue } let constants = scratch.cache.write( DirectionalShadowViewUniform(viewProjection: entry.matrices[index], tileBounds: [tile.minX, tile.minY, tile.maxX, tile.maxY]), view: source.entityId, @@ -266,35 +358,27 @@ public struct LocalShadow3DRenderNode: RenderNode { device: device.renderDevice ) pass.setVertexBuffer(constants, offset: 0, slot: GlobalBufferIndex.viewUniform) - for item in lists.items(view: source.entityId, pass: 100 + index) { - guard item.castShadows, let range = item.batchRange, (item.material as? PBRMaterial)?.effectiveAlphaMode != .blend else { continue } - let part = item.mesh.models[item.modelIndex].parts[item.partIndex] - guard let material = materials.buffer(for: item.material), let palette = item.skinningBuffer ?? skinning.identityBuffer else { continue } - let morphed = item.morphBuffer != nil && part.morphTargets != nil - let pipeline = pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.vertexDescriptor, morph: morphed), device: device.renderDevice) - guard ShaderGraph3DPipeline.bindShadow(encoder: pass, base: pipeline, material: item.material, device: device.renderDevice, morph: morphed) else { continue } - if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { - pass.setResourceSet(RenderResourceSet(bindings: [ - .init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0)), - .init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture)), - ]), index: 0) - } - pass.setVertexBuffer(part.vertexBuffer, offset: 0, slot: 0) - pass.setVertexBuffer(instances, offset: Int(range.lowerBound) * MemoryLayout.stride, slot: 3) - if let defaults = buffers.defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } - let base = (item.material as? PBRMaterial)?.baseColorTexture ?? Texture2D.whiteTexture - pass.setResourceSet( - .init(bindings: [ - .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(material, offset: 0)), - .init(binding: 4, shaderStages: .fragment, resource: .texture(base)), - .init(binding: 7, shaderStages: .fragment, resource: .sampler(base.sampler)), - .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), - ]), - index: 0 + if let instances = buffers.instances { + encodedDrawCount += batches.encode( + items: lists.items(view: source.entityId, pass: 100 + index), + instances: instances, + defaults: buffers.defaults, + pass: pass, + pipelines: &_pipelines.wrappedValue, + materials: materials, + identityPalette: skinning.identityBuffer, + device: device.renderDevice + ) + } + if let culled { + encodedDrawCount += GPUShadowCull3D.draw( + registry: registry, + output: culled, + view: viewIndex, + pass: pass, + pipelines: &_pipelines.wrappedValue, + device: device.renderDevice ) - pass.setIndexBuffer(part.indexBuffer, offset: 0) - pass.drawIndexed(indexCount: part.indexCount, indexBufferOffset: 0, instanceCount: Int(range.count)) - encodedDrawCount += 1 } } pass.endRenderPass() diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift index e8e99c45d..aa2a994b2 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift @@ -16,6 +16,12 @@ public struct PathTracing3DStatus: Codable, Sendable { public var height = 0 public var failure: String? public var implementation = "portable-shader-bvh" + /// Whether the first hit came from the raster G-buffer (full model detail) rather than a traced primary ray. + public var hybridFirstHit = false + /// The denoiser that ran: `rayReconstruction`, `portable` or `off` (see ``PathTracingDenoiser``). + public var denoiser = PathTracingDenoiser.portable.rawValue + /// Why the requested denoiser was not used, if it was not. + public var denoiserFallback: String? } /// Actual per-view encode/failure counters; enabling a setting alone is not proof of ray tracing. @@ -37,16 +43,40 @@ struct PathTracing3DUniform: Sendable { var horizon: Vector4 var ground: Vector4 var sky: Vector4 + /// x: the first hit comes from the raster G-buffer (path-tracing surface outputs present). + var hybrid: Vector4 + /// Unjittered view-projection and view of this view's previous traced frame, for reprojection. + var previousViewProjection: Transform3D + var previousView: Transform3D + /// x: wavelet step, y: minimum history weight, z: trace writes demodulated light, w: final pass remodulates. + var denoise: Vector4 } struct PathTracing3DViews: Resource { struct Entry: Sendable { + /// Accumulated light of the previous and the current frame. var targets: [RenderTexture] - var filtered: RenderTexture + /// This frame's raw samples (portable denoiser only). + var trace: RenderTexture + /// World normal and camera distance of the previous and the current frame, validating reprojection. + var guides: [RenderTexture] + /// Ping-pong targets of the wavelet iterations. + var filters: [RenderTexture] var index = 0 + var previousViewProjection: Transform3D? + var previousView: Transform3D? + var denoiser: PathTracingDenoiser? + /// Ray Reconstruction guide targets, and the guides of this frame when Ray Reconstruction denoises it. + var guideTargets: [RenderTexture] = [] + var rayReconstructionGuides: RayReconstructionGuides? var digest: UInt64? var status = PathTracing3DStatus() var active = false + /// Light table reused while the lights are unchanged. A changed table gets a new texture, because + /// frames still in flight may read the previous one. + var lightTable: Texture2D? + var lightTableDigest: UInt64? + var lightTableBuilds = 0 } var scene = PathTracing3DScene() var entries: [Entity.ID: Entry] = [:] @@ -106,7 +136,8 @@ public struct PathTracing3DRenderNode: RenderNode { if resources.entries.isEmpty { resources.scene = PathTracing3DScene() } return [] } - let sources = context.world.getResource(ExtractedMesh3DSources.self)?.meshes ?? [] + // Raster-only detail is seen through the G-buffer first hit; the tracer intersects its proxies instead. + let sources = context.world.getResource(ExtractedMesh3DSources.self)?.pathTracedMeshes ?? [] var scene = resources.scene do { try await scene.prepare(sources,skinning:context.world.getResource(Skinning3DUniforms.self),device:device.renderDevice, textureTileSize:settings.textureTileSize) } @@ -121,15 +152,33 @@ public struct PathTracing3DRenderNode: RenderNode { return [] } resources.scene = scene - let size = SizeInt(width:max(1,Int(Float(color.size.width)*settings.resolutionScale)),height:max(1,Int(Float(color.size.height)*settings.resolutionScale))) + // Ray Reconstruction needs DLSS temporal upscaling of this view and G-buffer guides; it then traces at input size, + // because it reconstructs from input-resolution samples (resolutionScale applies to the other denoisers). + let hybridAvailable = target.pathTracingSurfaceActive && target.diffuseSurface3DTexture != nil && target.reflectionResponse3DTexture != nil + let reconstructionUnavailable: String? = !target.temporalUpscalingActive || !target.rayReconstructionAvailable + ? "DLSS Ray Reconstruction is unavailable for this view" + : !hybridAvailable ? "Ray Reconstruction needs the G-buffer first hit" : nil + let usesReconstruction = reconstructionUnavailable == nil && (settings.denoiser == .automatic || settings.denoiser == .rayReconstruction) + let size = usesReconstruction ? color.size + : SizeInt(width:max(1,Int(Float(color.size.width)*settings.resolutionScale)),height:max(1,Int(Float(color.size.height)*settings.resolutionScale))) var entry = resources.entries.removeValue(forKey:id) if entry?.targets.first?.size != size { - entry = .init(targets:(0..<2).map { _ in RenderTexture(size:size,scaleFactor:1,format:.rgba_16f,debugLabel:"Path tracing accumulation") }, - filtered:RenderTexture(size:size,scaleFactor:1,format:.rgba_16f,debugLabel:"Path tracing spatial filter")) + func texture(_ label: String) -> RenderTexture { RenderTexture(size:size,scaleFactor:1,format:.rgba_16f,debugLabel:label) } + entry = .init(targets:(0..<2).map { _ in texture("Path tracing accumulation") }, trace:texture("Path tracing samples"), + guides:(0..<2).map { _ in texture("Path tracing reprojection guide") }, filters:(0..<2).map { _ in texture("Path tracing wavelet filter") }) } guard var entry, let nodes = scene.nodes, let triangles = scene.triangles, let materials = scene.materials, let atlas = scene.atlas else { return [] } + let denoiser: PathTracingDenoiser + switch settings.denoiser { + case .off: denoiser = .off + case .portable: denoiser = .portable + case .automatic, .rayReconstruction: denoiser = usesReconstruction ? .rayReconstruction : .portable + } + let denoises = denoiser != .off var hash = FrameDigest3D() - hash.combine(scene.generation); hash.combine(uniform.viewMatrix); hash.combine(uniform.projectionMatrix) + hash.combine(scene.generation) + if !denoises { hash.combine(uniform.viewMatrix); hash.combine(uniform.projectionMatrix) } + hash.combine(UInt64(denoiser == .off ? 0 : (denoiser == .portable ? 1 : 2))) hash.combine(UInt64(settings.samplesPerPixel)); hash.combine(UInt64(settings.maximumBounces)); hash.combine(UInt64(settings.historyLimit)) hash.combine(settings.maximumDistance); hash.combine(settings.rayBias); hash.combine(settings.maximumRadiance) var lights: [Vector4] = [] @@ -146,7 +195,16 @@ public struct PathTracing3DRenderNode: RenderNode { lights += [Vector4(lamp.position,lamp.kind == .point ? 1 : 2),Vector4(lamp.radiance*lamp.intensity,lamp.range), Vector4(lamp.direction,lamp.castsShadows ? 1 : 0),Vector4(cone.x,cone.y,0,0)] } - for value in lights { hash.combine(value) } + if denoises { + // Moving lights keep history (reprojection and the history weight fade it); adding, removing, switching + // or retuning a light restarts it. Records: position|direction + kind, radiance + range, axis + shadows, cone. + hash.combine(UInt64(lights.count)) + for index in stride(from:0,to:lights.count,by:4) { + hash.combine(lights[index].w); hash.combine(lights[index+1]); hash.combine(lights[index+2].w); hash.combine(lights[index+3]) + } + } else { + for value in lights { hash.combine(value) } + } let sky = environment.skybox let linearEnvironment = environment.skyboxUsesImageBasedLighting && environment.imageBasedLighting?.asset.asset?.radiance != nil let environmentTexture = environment.skyboxUsesImageBasedLighting ? environment.imageBasedLighting?.asset.asset?.radiance ?? sky.texture?.asset : sky.texture?.asset @@ -155,25 +213,79 @@ public struct PathTracing3DRenderNode: RenderNode { hash.combine(sky.zenithColor.asVector); hash.combine(sky.horizonColor.asVector); hash.combine(sky.groundColor.asVector); hash.combine(skyParameters) hash.combine(linearEnvironment ? UInt64(1) : 0) if let environmentTexture { hash.combine(UInt64(bitPattern:Int64(ObjectIdentifier(environmentTexture).hashValue))) } + let historyRestarted = entry.digest != hash.value || entry.denoiser != denoiser if entry.digest != hash.value { entry.status.accumulatedFrames = 0; entry.status.historyResets += 1; entry.digest = hash.value } - let history = entry.targets[entry.index] + let previous = entry.index entry.index = 1-entry.index - let output = entry.targets[entry.index] + let current = entry.index let pipeline = resources.getPipeline(device.renderDevice) guard let nearest = resources.nearest, let linear = resources.linear else { return [] } - let lightTexture = PathTracing3DScene.dataTexture(lights,label:"Path tracing all lights") + var lightDigest = FrameDigest3D() + for value in lights { lightDigest.combine(value) } + lightDigest.combine(UInt64(lights.count)) + let lightTexture: Texture2D + if let table = entry.lightTable, entry.lightTableDigest == lightDigest.value { + lightTexture = table + } else { + lightTexture = PathTracing3DScene.dataTexture(lights,label:"Path tracing all lights") + entry.lightTable = lightTexture; entry.lightTableDigest = lightDigest.value; entry.lightTableBuilds += 1 + } + // Full-detail first hit: base colour and emission written by the main pass for path-traced views. + let hybridSurface = target.pathTracingSurfaceActive + ? target.diffuseSurface3DTexture.flatMap { albedo in target.reflectionResponse3DTexture.map { (albedo,$0) } } : nil let jittered = temporalViewUniform(uniform,target:target) + let viewProjection = uniform.projectionMatrix * uniform.viewMatrix var constants = PathTracing3DUniform(inverseProjection:jittered.projectionMatrix.inverse,inverseView:uniform.viewMatrix.inverse, counts:Vector4(Float(scene.nodeCount),Float(lights.count/4),Float(settings.samplesPerPixel),Float(settings.maximumBounces)), trace:Vector4(settings.maximumDistance,settings.rayBias,settings.maximumRadiance,Float(min(entry.status.accumulatedFrames,settings.historyLimit-1))), frame:Vector4(Float(entry.status.encodedFrames%1000000),0,Float(atlas.height/scene.tileSize),linearEnvironment ? 1 : 0), - zenith:sky.zenithColor.asVector,horizon:sky.horizonColor.asVector,ground:sky.groundColor.asVector,sky:skyParameters) + zenith:sky.zenithColor.asVector,horizon:sky.horizonColor.asVector,ground:sky.groundColor.asVector,sky:skyParameters, + hybrid:Vector4(hybridSurface == nil ? 0 : 1,0,0,0), + previousViewProjection:entry.previousViewProjection ?? viewProjection,previousView:entry.previousView ?? uniform.viewMatrix, + denoise:Vector4(1,settings.minimumHistoryWeight,denoises ? 1 : 0,denoises ? 1 : 0)) + // Shader passes: 0 trace, 1 temporal, 2 guide, 3 wavelet, 4 final. Inputs never alias the pass's destination. + struct Step { var shader: Int; var destination: RenderTexture; var a: Texture; var b: Texture?; var c: Texture?; var spacing: Float; var label: String } + var steps: [Step] = [] + entry.rayReconstructionGuides = nil + if denoiser == .rayReconstruction { + // One fresh sample per frame: Ray Reconstruction keeps its own history and denoises at upscale. + constants.trace.w = 0 + constants.denoise.z = 0 + constants.denoise.w = 0 + if entry.guideTargets.first?.size != size { + entry.guideTargets = ["Path tracing RR diffuse albedo","Path tracing RR specular albedo","Path tracing RR normal roughness"] + .map { RenderTexture(size:size,scaleFactor:1,format:.rgba_16f,debugLabel:$0) } + } + steps.append(Step(shader:0,destination:entry.trace,a:entry.targets[previous],b:nil,c:nil,spacing:1,label:"Path tracing rays")) + for (selector, guide) in entry.guideTargets.enumerated() { + steps.append(Step(shader:5,destination:guide,a:entry.trace,b:nil,c:nil,spacing:Float(selector),label:"Path tracing RR guide")) + } + steps.append(Step(shader:4,destination:color,a:entry.trace,b:nil,c:nil,spacing:1,label:"Path tracing reconstruction")) + entry.rayReconstructionGuides = RayReconstructionGuides(diffuseAlbedo:entry.guideTargets[0],specularAlbedo:entry.guideTargets[1], + normalRoughness:entry.guideTargets[2],worldToView:uniform.viewMatrix,viewToClip:uniform.projectionMatrix,reset:historyRestarted) + } else if denoises { + steps.append(Step(shader:0,destination:entry.trace,a:entry.targets[previous],b:nil,c:nil,spacing:1,label:"Path tracing rays")) + steps.append(Step(shader:1,destination:entry.targets[current],a:entry.trace,b:entry.targets[previous],c:entry.guides[previous], + spacing:1,label:"Path tracing temporal reprojection")) + steps.append(Step(shader:2,destination:entry.guides[current],a:entry.trace,b:nil,c:nil,spacing:1,label:"Path tracing reprojection guide")) + var input: Texture = entry.targets[current] + for (iteration, spacing) in [Float(1),2,4,8].enumerated() { + let output = entry.filters[iteration % 2] + steps.append(Step(shader:3,destination:output,a:input,b:nil,c:nil,spacing:spacing,label:"Path tracing wavelet filter")) + input = output + } + steps.append(Step(shader:4,destination:color,a:input,b:nil,c:nil,spacing:1,label:"Path tracing reconstruction")) + } else { + steps.append(Step(shader:0,destination:entry.targets[current],a:entry.targets[previous],b:nil,c:nil,spacing:1,label:"Path tracing rays")) + steps.append(Step(shader:4,destination:color,a:entry.targets[current],b:nil,c:nil,spacing:1,label:"Path tracing reconstruction")) + } let command = renderContext.commandQueue.makeCommandBuffer(); command.label = "World-space path tracing" - for passIndex in 0..<3 { - let destination = passIndex == 0 ? output : (passIndex == 1 ? entry.filtered : color) - constants.frame.y = Float(passIndex) + for (passIndex, step) in steps.enumerated() { + let destination = step.destination + constants.frame.y = Float(step.shader) + constants.denoise.x = step.spacing let buffer = resources.uniforms.write(constants,view:id,pass:passIndex,device:device.renderDevice) - let label = passIndex == 0 ? "Path tracing rays" : (passIndex == 1 ? "Path tracing spatial filter" : "Path tracing reconstruction") + let label = step.label let pass = command.beginRenderPass(.init(label:label, colorAttachments:[.init(texture:destination,operation:.init(loadAction:.clear,storeAction:.store))])) pass.setViewport(Rect(x:0,y:0,width:Float(destination.size.width),height:Float(destination.size.height))) @@ -182,13 +294,23 @@ public struct PathTracing3DRenderNode: RenderNode { .init(binding:0,shaderStages:.fragment,resource:.texture(nodes)), .init(binding:1,shaderStages:.fragment,resource:.texture(triangles)), .init(binding:2,shaderStages:.fragment,resource:.texture(materials)), .init(binding:3,shaderStages:.fragment,resource:.texture(atlas)), .init(binding:5,shaderStages:.fragment,resource:.texture(lightTexture)), - .init(binding:6,shaderStages:.fragment,resource:.texture(passIndex == 0 ? history : (passIndex == 1 ? output : entry.filtered))), + .init(binding:6,shaderStages:.fragment,resource:.texture(step.a)), .init(binding:7,shaderStages:.fragment,resource:.texture(environmentTexture ?? Texture2D.whiteTexture)), .init(binding:8,shaderStages:.fragment,resource:.sampler(nearest)), .init(binding:9,shaderStages:.fragment,resource:.sampler(linear)), - .init(binding:10,shaderStages:.fragment,resource:.texture(normal)), .init(binding:11,shaderStages:.fragment,resource:.texture(position))]),index:0) + .init(binding:10,shaderStages:.fragment,resource:.texture(normal)), .init(binding:11,shaderStages:.fragment,resource:.texture(position)), + .init(binding:12,shaderStages:.fragment,resource:.texture(hybridSurface?.0 ?? Texture2D.whiteTexture)), + .init(binding:13,shaderStages:.fragment,resource:.texture(hybridSurface?.1 ?? Texture2D.whiteTexture)), + .init(binding:14,shaderStages:.fragment,resource:.texture(step.b ?? Texture2D.whiteTexture)), + .init(binding:15,shaderStages:.fragment,resource:.texture(step.c ?? Texture2D.whiteTexture))]),index:0) pass.draw(type:.triangle,vertexStart:0,vertexCount:3,instanceCount:1); pass.endRenderPass() } entry.status.encodedFrames += 1; entry.status.accumulatedFrames = min(entry.status.accumulatedFrames+1,settings.historyLimit) + entry.status.hybridFirstHit = hybridSurface != nil + entry.status.denoiser = denoiser.rawValue + entry.status.denoiserFallback = settings.denoiser == .rayReconstruction && denoiser != .rayReconstruction ? reconstructionUnavailable : nil + entry.previousViewProjection = viewProjection + entry.previousView = uniform.viewMatrix + entry.denoiser = denoiser entry.status.triangles = scene.triangleCount; entry.status.lights = lights.count/4; entry.status.width = size.width; entry.status.height = size.height entry.active = true; resources.entries[id] = entry; resources.lastFailure = nil; statistics.record(entry.status,view:id) if let metrics { command.addCompletedTimingHandler { metrics.record(pass:"pathTracing",seconds:$0) }; await metrics.commit(command) } diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift index 3bf9b8284..2df8c5350 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceReflectionRenderNode.swift @@ -177,6 +177,8 @@ public struct ScreenSpaceReflectionRenderNode: RenderNode { // The traced HDR already owns every surface lighting term. Keep only presentation/fog here. uniform.reflection.x = 0 uniform.quality.x = 0 + // The response target holds the tracer's emission guide, not a specular response. + uniform.quality.w = 0 uniform.diffuseGI = [0,0,1,0] } let constants = scratch.cache.write(uniform, view: source.entityId, device: renderDevice.renderDevice) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Temporal3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Temporal3DRenderNode.swift index e4d151278..d3a187986 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Temporal3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Temporal3DRenderNode.swift @@ -116,7 +116,11 @@ public struct Temporal3DRenderNode: RenderNode { } mesh.endRenderPass() - let encoded = scaler.encode( + let guides = context.world.getResource(PathTracing3DViews.self)?.entries[source.entityId]?.rayReconstructionGuides + let encoded = guides.map { + scaler.encodeDenoised(commandBuffer: command, color: input, depth: depth, motion: motion, reactive: reactive, + guides: $0, output: output, jitter: target.temporalJitter, reset: target.temporalReset || $0.reset) + } == true || scaler.encode( commandBuffer: command, color: input, depth: depth, diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift index 0b57cae1b..c58511565 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift @@ -251,7 +251,9 @@ final class PBR3DShaderVariants: @unchecked Sendable { assertionFailure("Shader variant \(features.rawValue) of \(path) failed to compile: \(error)") // A plain shader has no fifth fragment output and cannot safely write a hybrid render pass. // Morph changes the vertex contract too. These defines remain mandatory even when a layer fails. - let required = features.intersection([.hybridReflection, .morph, .diffuseSurface, .lightmap, .irradianceGrid, .terrain, .wireframe]) + let required = features.intersection([ + .hybridReflection, .morph, .diffuseSurface, .lightmap, .irradianceGrid, .terrain, .wireframe, .pathTracingSurface, + ]) if required.isEmpty { variant = fallback } else { diff --git a/Sources/AdaCorePipelines/CorePipelines/ShadowBatchEncoder3D.swift b/Sources/AdaCorePipelines/CorePipelines/ShadowBatchEncoder3D.swift new file mode 100644 index 000000000..8e9cc7973 --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/ShadowBatchEncoder3D.swift @@ -0,0 +1,176 @@ +@_spi(Internal) import AdaRender +import AdaUtils + +/// Encodes shadow-depth draws for prepared visibility lists. +/// +/// A moving point light redraws six faces every frame, and each face draws most of the same batches. +/// The draw state of a batch (pipeline, material bindings, buffers) does not depend on the face, so it +/// is resolved once per encoder and reused by every face and cascade that draws the batch. +/// +/// Within a face, draws are grouped by draw state. Shadow passes write the nearest depth with a +/// strict depth test, so the stored result does not depend on draw order. Each face binds its +/// instance buffer once and selects a batch's instances with `firstInstance`. +struct ShadowBatchEncoder3D { + private struct Key: Hashable { + var material: ObjectIdentifier + var vertex: ObjectIdentifier + var index: ObjectIdentifier + var skin: ObjectIdentifier? + var morph: ObjectIdentifier? + } + + private struct State { + var pipeline: any RenderPipeline + /// Masked shader-graph materials bind a graph pipeline and the graph's own resources. + var material: Material? + var morphed: Bool + var resources: RenderResourceSet? + var morphResources: RenderResourceSet? + var vertexBuffer: any VertexBuffer + var indexBuffer: any IndexBuffer + var indexCount: Int + } + + private struct Draw { + var state: Int32 + var firstInstance: Int32 + var instanceCount: Int32 + } + + private var keys: [Key: Int32] = [:] + private var states: [State?] = [] + private var draws: [Draw] = [] + + /// Items without a material parameter buffer or skinning palette are skipped instead of drawing + /// with the previous draw's bindings. + let requiresMaterialBuffer: Bool + + init(requiresMaterialBuffer: Bool) { + self.requiresMaterialBuffer = requiresMaterialBuffer + } + + /// Number of distinct draw states resolved so far (for tests and statistics). + var resolvedStateCount: Int { states.count } + + /// Encodes every shadow-casting, non-blended item. The view uniform must already be bound. + /// Returns the number of draws issued. + @discardableResult + mutating func encode( + items: [Opaque3DRenderItem], + instances: BufferData, + defaults: BufferData?, + pass: RenderCommandEncoder, + pipelines: inout RenderPipelines

, + materials: PBR3DUniforms, + identityPalette: (any UniformBuffer)?, + device: RenderDevice + ) -> Int where P.Configuration == Shadow3DConfiguration { + draws.removeAll(keepingCapacity: true) + for item in items { + guard item.castShadows, let range = item.batchRange, !range.isEmpty else { continue } + let state = resolve(item, pipelines: &pipelines, materials: materials, identityPalette: identityPalette, device: device) + guard state >= 0 else { continue } + draws.append(Draw(state: state, firstInstance: range.lowerBound, instanceCount: Int32(range.count))) + } + guard !draws.isEmpty else { + return 0 + } + // Stable grouping keeps the list order within a state. + draws.sort { $0.state == $1.state ? $0.firstInstance < $1.firstInstance : $0.state < $1.state } + pass.setVertexBuffer(instances, offset: 0, slot: 3) + if let defaults { pass.setVertexBuffer(defaults, offset: 0, slot: 4) } + var current: Int32 = -1 + var encoded = 0 + for draw in draws { + guard let state = states[Int(draw.state)] else { continue } + if draw.state != current || state.material != nil { + if let material = state.material { + guard ShaderGraph3DPipeline.bindShadow(encoder: pass, base: state.pipeline, material: material, device: device, morph: state.morphed) else { + current = -1 + continue + } + } else { + pass.setRenderPipelineState(state.pipeline) + } + if let morphResources = state.morphResources { pass.setResourceSet(morphResources, index: 0) } + if let resources = state.resources { pass.setResourceSet(resources, index: 0) } + pass.setVertexBuffer(state.vertexBuffer, offset: 0, slot: 0) + pass.setIndexBuffer(state.indexBuffer, offset: 0) + current = draw.state + } + pass.drawIndexed( + indexCount: state.indexCount, + indexBufferOffset: 0, + instanceCount: Int(draw.instanceCount), + firstInstance: Int(draw.firstInstance) + ) + encoded += 1 + } + return encoded + } + + /// Returns the state index for an item, or -1 when the item is not drawn into shadows. + private mutating func resolve( + _ item: Opaque3DRenderItem, + pipelines: inout RenderPipelines

, + materials: PBR3DUniforms, + identityPalette: (any UniformBuffer)?, + device: RenderDevice + ) -> Int32 where P.Configuration == Shadow3DConfiguration { + let part = item.mesh.models[item.modelIndex].parts[item.partIndex] + let key = Key( + material: ObjectIdentifier(item.material), + vertex: ObjectIdentifier(part.vertexBuffer), + index: ObjectIdentifier(part.indexBuffer), + skin: item.skinningBuffer.map { ObjectIdentifier($0) }, + morph: item.morphBuffer.map { ObjectIdentifier($0) } + ) + if let index = keys[key] { + return states[Int(index)] == nil ? -1 : index + } + let index = Int32(states.count) + keys[key] = index + let pbr = item.material as? PBRMaterial + let materialBuffer = materials.buffer(for: item.material) + let palette = item.skinningBuffer ?? identityPalette + guard pbr?.effectiveAlphaMode != .blend, !requiresMaterialBuffer || (materialBuffer != nil && palette != nil) else { + states.append(nil) + return -1 + } + let morphed = item.morphBuffer != nil && part.morphTargets != nil + let pipeline = pipelines.pipeline(for: Shadow3DConfiguration(vertex: part.vertexDescriptor, morph: morphed), device: device) + var bindings: [RenderResourceSet.Binding] = [] + if let materialBuffer { + let base = pbr?.baseColorTexture ?? Texture2D.whiteTexture + bindings += [ + .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(materialBuffer, offset: 0)), + .init(binding: 4, shaderStages: .fragment, resource: .texture(base)), + .init(binding: 7, shaderStages: .fragment, resource: .sampler(base.sampler)), + ] + } + if let palette { + bindings.append(.init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0))) + } + var morphResources: RenderResourceSet? + if morphed, let morphBuffer = item.morphBuffer, let morphSet = part.morphTargets { + morphResources = RenderResourceSet(bindings: [ + .init(binding: Morph3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(morphBuffer, offset: 0)), + .init(binding: Morph3DUniforms.textureBinding, shaderStages: .vertex, resource: .texture(morphSet.deltaTexture)), + ]) + } + let usesGraph = pbr.map { $0.effectiveAlphaMode == .mask && $0.graphMaterial != nil } ?? false + states.append( + State( + pipeline: pipeline, + material: usesGraph ? item.material : nil, + morphed: morphed, + resources: bindings.isEmpty ? nil : RenderResourceSet(bindings: bindings), + morphResources: morphResources, + vertexBuffer: part.vertexBuffer, + indexBuffer: part.indexBuffer, + indexCount: part.indexCount + ) + ) + return index + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/ShadowCasterRegistry3D.swift b/Sources/AdaCorePipelines/CorePipelines/ShadowCasterRegistry3D.swift new file mode 100644 index 000000000..bef8e5a04 --- /dev/null +++ b/Sources/AdaCorePipelines/CorePipelines/ShadowCasterRegistry3D.swift @@ -0,0 +1,671 @@ +import AdaECS +@_spi(Internal) import AdaRender +import AdaUtils +import Foundation +import Math + +/// Static shadow casters drawn by GPU-culled indirect draws, shared by every shadow view of a frame. +/// +/// A caster is one instance of one mesh part. Its geometry is copied once into a shared buffer per vertex +/// layout, so many parts draw from the same buffers. Casters whose shadow cannot be reproduced exactly +/// without per-view CPU work (skinned, morphed, level of detail, distance fade, shader-graph masks) are left +/// in ``cpuSources`` for the CPU-culled path. See `Documentation/Designs/gpu-driven-shadow-batching.md`. +public struct ShadowCasterRegistry3D: Resource { + /// Identity of a source part: its buffers and index count. + struct PartKey: Hashable { + var vertex: ObjectIdentifier + var index: ObjectIdentifier + var indexCount: Int + } + + /// Opaque casters only depend on their material for single- or double-sided rejection, so they share a draw + /// group per pool and sidedness. Alpha-masked casters keep their own material buffer and base texture. + enum GroupKey: Hashable { + case opaque(pool: Int, doubleSided: Bool) + case masked(pool: Int, buffer: ObjectIdentifier, texture: ObjectIdentifier?) + + var pool: Int { + switch self { + case let .opaque(pool, _), let .masked(pool, _, _): pool + } + } + } + + /// One indirect draw per view: a pooled part drawn with a group's state. + struct DrawPart: Hashable { + var group: GroupKey + var part: PartKey + } + + /// Draw state shared by consecutive parts. + struct Group { + var key: GroupKey + var descriptor: VertexDescriptor + var material: any UniformBuffer + var texture: Texture2D + var palette: any UniformBuffer + var firstPart: Int + var partCount: Int + + /// Material parameters, base colour for alpha masks, and the identity skinning palette. + var resources: RenderResourceSet { + RenderResourceSet(bindings: [ + .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(material, offset: 0)), + .init(binding: 4, shaderStages: .fragment, resource: .texture(texture)), + .init(binding: 7, shaderStages: .fragment, resource: .sampler(texture.sampler)), + .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), + ]) + } + } + + private struct CasterKey: Hashable { + var entity: Entity.ID + var instance: UInt64 + var placed: Bool + var model: Int32 + var part: Int32 + } + + private struct Snapshot: Equatable { + var bounds: AABB? + var draw: DrawPart + var record: Flat3DInstanceData + } + + private struct PooledPart { + let vertexSource: any VertexBuffer + let indexSource: any IndexBuffer + let vertexBytes: Int + let indexBytes: Int + var vertexOffset = 0 + var indexOffset = 0 + var lastUsed: UInt64 + } + + private struct Pool { + let descriptor: VertexDescriptor + let stride: Int + var vertex: (any VertexBuffer)? + var index: (any IndexBuffer)? + var vertexUsed = 0 + var indexUsed = 0 + var parts: [PartKey: PooledPart] = [:] + var needsRebuild = false + } + + /// A large static part culled per meshlet cluster. It is drawn from its own buffers, never pooled. + struct ClusterPart { + var descriptor: VertexDescriptor + var vertex: any VertexBuffer + var index: any IndexBuffer + var material: any UniformBuffer + var palette: any UniformBuffer + + /// Material parameters (opaque, so no base texture) and the identity skinning palette. + var resources: RenderResourceSet { + RenderResourceSet(bindings: [ + .init(binding: 15, shaderStages: .fragment, resource: .uniformBuffer(material, offset: 0)), + .init(binding: 4, shaderStages: .fragment, resource: .texture(Texture2D.whiteTexture)), + .init(binding: 7, shaderStages: .fragment, resource: .sampler(Texture2D.whiteTexture.sampler)), + .init(binding: Skinning3DUniforms.binding, shaderStages: .vertex, resource: .uniformBuffer(palette, offset: 0)), + ]) + } + } + + /// Everything that decides a cluster caster's clusters, record and draw state. + private struct ClusterSnapshot: Equatable { + var entity: Entity.ID + var model: Int32 + var part: Int32 + var vertex: ObjectIdentifier + var index: ObjectIdentifier + var geometry: UUID + var descriptor: VertexDescriptor + var material: ObjectIdentifier + var record: Flat3DInstanceData + } + + private struct Copy { + let source: any Buffer + let destination: any Buffer + let destinationOffset: Int + let size: Int + } + + /// Frames a pooled part may stay unused before a rebuild may drop it. + private static let retention: UInt64 = 300 + /// Largest pool buffer: the baseline WebGPU `maxBufferSize`. Larger scenes keep the CPU path. + static let maximumPoolBytes = 256 << 20 + + /// Smallest part, in meshlet clusters, that is culled per cluster. Smaller parts cost more as separate draws + /// than their culled clusters save, so they stay per-caster casters. + static let minimumClusterCasterClusters = 256 + + /// Disable with `ADAENGINE_SHADOW_GPU=0` to draw every caster on the CPU-culled path. + public var isEnabled = ProcessInfo.processInfo.environment["ADAENGINE_SHADOW_GPU"] != "0" + + /// The current casters, instance records and pooled parts. + public private(set) var input = GPUShadowCullInput() + /// Casters that keep the CPU-culled path. + public private(set) var cpuSources: [Mesh3DRenderSource] = [] + /// Increments whenever ``input`` or the draw groups change. + public private(set) var revision: UInt64 = 0 + /// Bounds of casters added, removed or changed by the last revision (old and new), or `nil` when the change + /// cannot be bounded and every shadow view must redraw. + public private(set) var changedBounds: [AABB]? = [] + + /// Clusters of the large static parts culled per cluster (see `Documentation/Designs/meshlet-shadow-culling.md`). + public private(set) var clusterInput = GPUShadowClusterInput() + /// ``GPUShadowClusterInput/isValid`` of the current cluster input, evaluated once per change. + public private(set) var isClusterInputValid = false + /// Draw state of each cluster caster part, in ``clusterInput`` segment order. + private(set) var clusterParts: [ClusterPart] = [] + private var clusterSnapshots: [ClusterSnapshot] = [] + + private(set) var groups: [Group] = [] + private(set) var defaults: BufferData? + private var pools: [Pool] = [] + private var poolIndex: [VertexDescriptor: Int] = [:] + private var snapshots: [CasterKey: Snapshot] = [:] + private var copies: [Copy] = [] + private var frame: UInt64 = 0 + /// Material frame and switch state of the last update, so the second shadow node of a frame reuses it. + private var updated: (frame: Int, enabled: Bool, palette: ObjectIdentifier?)? + + public init() {} + + /// Number of indirect draws per shadow view. + public var partCount: Int { input.parts.count } + + /// Bytes of shared geometry held for shadow casters. + public var pooledBytes: Int { pools.reduce(0) { $0 + ($1.vertex?.length ?? 0) + ($1.index?.length ?? 0) } } + + /// Whether any caster is drawn by the per-caster GPU path. + public var hasCasters: Bool { !input.casters.isEmpty } + + /// Whether any part is drawn by the per-cluster GPU path. + public var hasClusterCasters: Bool { !clusterInput.clusters.isEmpty } + + /// Builds the GPU-path input on devices without GPU culling (headless unit tests only). + var buildsWithoutDeviceSupport = false + /// Builds cluster casters on devices without GPU cluster culling (headless unit tests only). + var buildsClustersWithoutDeviceSupport = false + /// Smallest cluster caster in clusters; lowered by unit tests. + var minimumClusterCount = minimumClusterCasterClusters + /// Largest pool buffer in bytes; lowered by unit tests. + var poolByteLimit = maximumPoolBytes + /// Number of pool buffer pairs allocated so far (for tests). + private(set) var poolAllocations = 0 + + /// ``GPUShadowCullInput/isValid`` of the current input, evaluated once per change. + public private(set) var isInputValid = false + + // MARK: - Update + + /// Splits `sources` into GPU-path casters and ``cpuSources``. Cheap when nothing changed: the arrays are rebuilt + /// and compared, but uploads and pool copies happen only on change. + public mutating func update( + sources: [Mesh3DRenderSource], + materials: inout PBR3DUniforms, + identityPalette: (any UniformBuffer)?, + device: RenderDevice + ) { + // Both shadow nodes update with the same extracted sources in one frame. + let state = (frame: materials.frame, enabled: isEnabled, palette: identityPalette.map { ObjectIdentifier($0) }) + if let updated, updated == state { return } + updated = state + frame &+= 1 + // Backends without GPU culling never build pools or queue copies. + guard isEnabled, device.supportsGPUShadowCull || buildsWithoutDeviceSupport, let identityPalette else { + cpuSources = sources.filter(\.castShadows) + clear() + return + } + var cpu: [Mesh3DRenderSource] = [] + var casters: [GPUShadowCaster] = [] + var records: [Flat3DInstanceData] = [] + var keys: [CasterKey] = [] + var draws: [DrawPart] = [] + var drawIndex: [DrawPart: UInt32] = [:] + var groupOrder: [GroupKey] = [] + var groupStates: [GroupKey: Group] = [:] + let buildsClusters = device.supportsGPUShadowClusterCull || buildsClustersWithoutDeviceSupport + var nextClusterParts: [ClusterPart] = [] + var nextClusterSnapshots: [ClusterSnapshot] = [] + var clusterGeometries: [(geometry: MeshletGeometry, transform: Transform3D)] = [] + for sourceIndex in sources.indices where sources[sourceIndex].castShadows { + guard Self.isEligible(sources[sourceIndex]) else { + cpu.append(sources[sourceIndex]) + continue + } + let mesh = sources[sourceIndex].mesh + let count = sources[sourceIndex].instances?.count ?? 1 + for ordinal in 0..= 0, let instances = sources[sourceIndex].instances { + transform = sources[sourceIndex].transform * instances[placement].transform + // Matches the CPU list: the prototype bounds (no alternatives here) moved by the placement. + bounds = MeshVisibility3DMath.transformed(mesh.bounds, by: transform) + tint = instances[placement].color + } else { + transform = sources[sourceIndex].transform + bounds = sources[sourceIndex].bounds + tint = .one + } + for modelIndex in mesh.models.indices { + let parts = mesh.models[modelIndex].parts + for partIndex in parts.indices { + let material = sources[sourceIndex].materials[parts[partIndex].materialIndex] + let pbr = material as? PBRMaterial + if pbr?.effectiveAlphaMode == .blend { continue } + let descriptor = parts[partIndex].vertexDescriptor + materials.write(material: material, descriptor: descriptor, device: device) + guard let buffer = materials.buffer(for: material) else { continue } + if buildsClusters, let geometry = clusterGeometry( + part: parts[partIndex], material: pbr, transform: transform, + isSingleton: placement < 0 && !sources[sourceIndex].forceVisible && bounds != nil + ) { + let record = Flat3DInstanceData( + modelMatrix: transform, + color: (pbr?.baseColorFactor ?? .one) * tint, + material: .zero, + textureFlags: .zero, + shadowFlags: .zero + ) + nextClusterSnapshots.append(ClusterSnapshot( + entity: sources[sourceIndex].entity, + model: Int32(modelIndex), + part: Int32(partIndex), + vertex: ObjectIdentifier(parts[partIndex].vertexBuffer), + index: ObjectIdentifier(parts[partIndex].indexBuffer), + geometry: geometry.id, + descriptor: descriptor, + material: ObjectIdentifier(buffer), + record: record + )) + nextClusterParts.append(ClusterPart( + descriptor: descriptor, + vertex: parts[partIndex].vertexBuffer, + index: parts[partIndex].indexBuffer, + material: buffer, + palette: identityPalette + )) + clusterGeometries.append((geometry, transform)) + continue + } + let pool = pool(for: descriptor) + let group: GroupKey = pbr?.effectiveAlphaMode == .mask + ? .masked(pool: pool, buffer: ObjectIdentifier(buffer), texture: pbr?.baseColorTexture.map(ObjectIdentifier.init)) + : .opaque(pool: pool, doubleSided: pbr?.effectiveDoubleSided ?? true) + if groupStates[group] == nil { + groupStates[group] = Group( + key: group, + descriptor: descriptor, + material: buffer, + texture: pbr?.baseColorTexture ?? Texture2D.whiteTexture, + palette: identityPalette, + firstPart: 0, + partCount: 0 + ) + groupOrder.append(group) + } + let partKey = PartKey( + vertex: ObjectIdentifier(parts[partIndex].vertexBuffer), + index: ObjectIdentifier(parts[partIndex].indexBuffer), + indexCount: parts[partIndex].indexCount + ) + retain(partKey, part: parts[partIndex], pool: pool) + let draw = DrawPart(group: group, part: partKey) + let index: UInt32 + if let existing = drawIndex[draw] { + index = existing + } else { + index = UInt32(draws.count) + drawIndex[draw] = index + draws.append(draw) + } + casters.append(GPUShadowCaster( + bounds: bounds, + forceVisible: sources[sourceIndex].forceVisible, + part: index, + instance: UInt32(records.count) + )) + records.append(Flat3DInstanceData( + modelMatrix: transform, + color: (pbr?.baseColorFactor ?? .one) * tint, + material: .zero, + textureFlags: .zero, + shadowFlags: .zero + )) + keys.append(CasterKey( + entity: sources[sourceIndex].entity, + instance: placement >= 0 ? sources[sourceIndex].instances?[placement].id ?? 0 : 0, + placed: placement >= 0, + model: Int32(modelIndex), + part: Int32(partIndex) + )) + } + } + } + } + cpuSources = cpu + guard let poolsChanged = rebuildPoolsIfNeeded(device: device) else { + // A pool would exceed the buffer size limit: every caster keeps the CPU path. + cpuSources = sources.filter(\.castShadows) + clear() + return + } + + // Parts are ordered by group so each group's draws are consecutive; segments follow that order. + let rank = Dictionary(uniqueKeysWithValues: groupOrder.enumerated().map { ($1, $0) }) + let order = draws.indices.sorted { (rank[draws[$0].group] ?? 0, $0) < (rank[draws[$1].group] ?? 0, $1) } + var remap = [UInt32](repeating: 0, count: draws.count) + for (position, original) in order.enumerated() { remap[original] = UInt32(position) } + var counts = [Int](repeating: 0, count: draws.count) + for index in casters.indices { + casters[index].part = remap[Int(casters[index].part)] + counts[Int(casters[index].part)] += 1 + } + var parts: [IndexedIndirectArguments] = [] + var segments: [UInt32] = [] + var sortedDraws: [DrawPart] = [] + var next: UInt32 = 0 + for original in order { + let draw = draws[original] + let pool = pools[draw.group.pool] + guard let pooled = pool.parts[draw.part] else { + // Every retained part is pooled; anything else would misalign parts and casters. + assertionFailure("Shadow caster part without pooled geometry") + cpuSources = sources.filter(\.castShadows) + clear() + return + } + sortedDraws.append(draw) + parts.append(IndexedIndirectArguments( + indexCount: UInt32(draw.part.indexCount), + firstIndex: UInt32(pooled.indexOffset / MemoryLayout.stride), + baseVertex: Int32(pooled.vertexOffset / pool.stride) + )) + segments.append(next) + next += UInt32(counts[parts.count - 1]) + } + var nextGroups: [Group] = [] + for (position, draw) in sortedDraws.enumerated() { + if let last = nextGroups.indices.last, nextGroups[last].key == draw.group { + nextGroups[last].partCount += 1 + } else if var group = groupStates[draw.group] { + group.firstPart = position + group.partCount = 1 + nextGroups.append(group) + } + } + + // Diff by caster identity, so shadow views that kept their matrix redraw only where something changed. + var current: [CasterKey: Snapshot] = [:] + current.reserveCapacity(keys.count) + var duplicate = false + for index in keys.indices { + let snapshot = Snapshot( + bounds: casters[index].minimum.w != 0 ? nil : AABB(min: casters[index].minimum.xyz, max: casters[index].maximum.xyz), + draw: draws[order[Int(casters[index].part)]], + record: records[index] + ) + if current.updateValue(snapshot, forKey: keys[index]) != nil { duplicate = true } + } + let instanceBytes = unsafe records.withUnsafeBytes { unsafe Array($0) } + let changed = poolsChanged || current != snapshots || casters != input.casters || parts.count != input.parts.count + || nextGroups.map(\.key) != groups.map(\.key) || instanceBytes != input.instances + if changed { + var bounds: [AABB]? = [] + if duplicate || poolsChanged { + bounds = nil + } else { + for (key, old) in snapshots where current[key] != old { + guard let box = old.bounds else { bounds = nil; break } + bounds?.append(box) + } + if bounds != nil { + for (key, new) in current where snapshots[key] != new { + guard let box = new.bounds else { bounds = nil; break } + bounds?.append(box) + } + } + } + changedBounds = bounds + revision &+= 1 + var next = GPUShadowCullInput() + next.casters = casters + next.instances = instanceBytes + next.instanceStride = MemoryLayout.stride + // The shadow pipelines read the model matrix, colour and shadow flags (first 128 bytes). + next.copiedBytes = MemoryLayout.offset(of: \.shadowFlags).map { $0 + MemoryLayout.stride } + ?? MemoryLayout.stride + next.parts = parts + next.segments = segments + next.revision = revision + input = next + isInputValid = next.isValid + snapshots = current + } + groups = nextGroups + clusterParts = nextClusterParts + if nextClusterSnapshots != clusterSnapshots { + // Cluster casters are large and rarely change: any change redraws every shadow view. + revision &+= 1 + changedBounds = nil + var next = GPUShadowClusterInput() + var records: [Flat3DInstanceData] = [] + for (part, entry) in clusterGeometries.enumerated() { + next.segments.append(UInt32(next.clusters.count)) + for cluster in entry.geometry.clusters { + let local = AABB(center: (cluster.minimum + cluster.maximum) * 0.5, halfExtents: (cluster.maximum - cluster.minimum) * 0.5) + next.clusters.append(GPUShadowCluster( + bounds: MeshVisibility3DMath.transformed(local, by: entry.transform), + firstIndex: UInt32(cluster.firstIndex), + indexCount: UInt32(cluster.indexCount), + part: UInt32(part), + instance: UInt32(part) + )) + } + records.append(nextClusterSnapshots[part].record) + } + next.instances = unsafe records.withUnsafeBytes { unsafe Array($0) } + next.instanceStride = MemoryLayout.stride + next.revision = revision + clusterInput = next + isClusterInputValid = next.isValid + clusterSnapshots = nextClusterSnapshots + } + // The default-attribute buffer holds identical records. WebGPU binds it with stride 0 (one shared record), + // so multi-draw instance indices beyond `casters.count` read that record; per-instance stepping backends + // never multi-draw and read at most one part's casters. + let defaultRecords = max(casters.count, clusterParts.count) + if defaultRecords > (defaults?.count ?? 0) { + var data = BufferData(label: "Shadow Caster Defaults", elements: []) + for _ in 0.. (vertex: any VertexBuffer, index: any IndexBuffer)? { + guard let vertex = pools[group.key.pool].vertex, let index = pools[group.key.pool].index else { return nil } + return (vertex, index) + } + + // MARK: - Eligibility + + /// Whether every part of `source` can be drawn by the GPU path with exactly the CPU path's result. + static func isEligible(_ source: borrowing Mesh3DRenderSource) -> Bool { + guard source.skinningBuffer == nil, source.morphBuffers.isEmpty, source.alternatives.isEmpty else { return false } + // A finite maximum distance fades the caster per view when distance culling is on. + if let maximum = source.maximumDistance, maximum.isFinite, maximum > 0 { return false } + for model in source.mesh.models { + for part in model.parts { + guard source.materials.indices.contains(part.materialIndex) else { return false } + if let pbr = source.materials[part.materialIndex] as? PBRMaterial { + if pbr.effectiveAlphaMode == .blend { continue } + if pbr.effectiveAlphaMode == .mask && pbr.graphMaterial != nil { return false } + } + var layouts = part.vertexDescriptor.layouts + let stride = layouts[0].stride + guard part.indexBuffer.indexFormat == .uInt32, part.indexCount > 0, + part.indexCount * MemoryLayout.stride <= part.indexBuffer.length, + stride > 0, stride % 4 == 0, part.vertexBuffer.length > 0, part.vertexBuffer.length % 4 == 0 + else { return false } + } + } + return true + } + + /// The meshlets of a part that is culled per cluster, or nil to keep it a per-caster caster. + /// + /// Matches `Meshlet3DEligibility`: a single static instance of an opaque standard PBR triangle list without + /// deformation or parallax, with an affine transform, so the cluster bounds describe exactly what is drawn. + private func clusterGeometry(part: Mesh.Part, material: PBRMaterial?, transform: Transform3D, isSingleton: Bool) -> MeshletGeometry? { + guard isSingleton, let material, material.graphMaterial == nil, material.effectiveAlphaMode == .opaque, + material.heightTexture == nil, part.primitiveTopology == .triangleList, part.morphTargets == nil, + let geometry = part.meshlets, geometry.clusters.count >= minimumClusterCount, + transform.x.w == 0, transform.y.w == 0, transform.z.w == 0, transform.w.w == 1 + else { return nil } + // `Mesh.Part.meshlets` is nil unless the clusters were validated against this part's buffers and index count. + return geometry + } + + // MARK: - Pools + + private mutating func clear() { + // Without GPU-path casters, the pools and the source buffers they retain are released. + pools = [] + poolIndex = [:] + copies = [] + clusterParts = [] + guard !input.casters.isEmpty || !groups.isEmpty || !clusterInput.clusters.isEmpty else { return } + clusterInput = GPUShadowClusterInput() + isClusterInputValid = false + clusterSnapshots = [] + input = GPUShadowCullInput() + isInputValid = false + groups = [] + snapshots = [:] + changedBounds = nil + revision &+= 1 + } + + private mutating func pool(for descriptor: VertexDescriptor) -> Int { + if let index = poolIndex[descriptor] { return index } + let index = pools.count + var layouts = descriptor.layouts + pools.append(Pool(descriptor: descriptor, stride: layouts[0].stride)) + poolIndex[descriptor] = index + return index + } + + private mutating func retain(_ key: PartKey, part: Mesh.Part, pool: Int) { + if pools[pool].parts[key] != nil { + pools[pool].parts[key]?.lastUsed = frame + return + } + var pooled = PooledPart( + vertexSource: part.vertexBuffer, + indexSource: part.indexBuffer, + vertexBytes: part.vertexBuffer.length, + indexBytes: part.indexCount * MemoryLayout.stride, + lastUsed: frame + ) + let stride = pools[pool].stride + let vertexOffset = (pools[pool].vertexUsed + stride - 1) / stride * stride + let indexOffset = pools[pool].indexUsed + if let vertex = pools[pool].vertex, let index = pools[pool].index, + vertexOffset + pooled.vertexBytes <= vertex.length, indexOffset + pooled.indexBytes <= index.length { + pooled.vertexOffset = vertexOffset + pooled.indexOffset = indexOffset + pools[pool].vertexUsed = vertexOffset + pooled.vertexBytes + pools[pool].indexUsed = indexOffset + pooled.indexBytes + copies.append(Copy(source: pooled.vertexSource, destination: vertex, destinationOffset: vertexOffset, size: pooled.vertexBytes)) + copies.append(Copy(source: pooled.indexSource, destination: index, destinationOffset: indexOffset, size: pooled.indexBytes)) + } else { + pools[pool].needsRebuild = true + } + pools[pool].parts[key] = pooled + } + + /// Reallocates pools that are full or mostly unused and copies their live parts again. + /// New buffers are used, so frames still in flight keep reading the old ones. + /// Returns whether any pool was rebuilt, or `nil` when a pool would exceed ``poolByteLimit``. + private mutating func rebuildPoolsIfNeeded(device: RenderDevice) -> Bool? { + // Check every pool against the limit before allocating anything, so an over-limit scene allocates nothing. + for pool in pools { + var vertexBytes = 0 + var indexBytes = 0 + for part in pool.parts.values where frame - part.lastUsed <= Self.retention { + vertexBytes = (vertexBytes + pool.stride - 1) / pool.stride * pool.stride + part.vertexBytes + indexBytes += part.indexBytes + } + guard vertexBytes <= poolByteLimit, indexBytes <= poolByteLimit else { return nil } + } + var rebuilt = false + for index in pools.indices { + let stale = pools[index].parts.filter { frame - $0.value.lastUsed > Self.retention } + let staleBytes = stale.values.reduce(0) { $0 + $1.vertexBytes + $1.indexBytes } + let allocated = (pools[index].vertex?.length ?? 0) + (pools[index].index?.length ?? 0) + guard pools[index].needsRebuild || (staleBytes > 4 << 20 && staleBytes * 2 > allocated) else { continue } + for key in stale.keys { pools[index].parts.removeValue(forKey: key) } + let stride = pools[index].stride + var vertexBytes = 0 + var indexBytes = 0 + for key in pools[index].parts.keys { + vertexBytes = (vertexBytes + stride - 1) / stride * stride + (pools[index].parts[key]?.vertexBytes ?? 0) + indexBytes += pools[index].parts[key]?.indexBytes ?? 0 + } + // Headroom avoids reallocating for every part streamed in. + let vertexCapacity = min(max(64 << 10, (vertexBytes + vertexBytes / 2 + stride - 1) / stride * stride), poolByteLimit / stride * stride) + let indexCapacity = min(max(64 << 10, (indexBytes + indexBytes / 2 + 3) & ~3), poolByteLimit & ~3) + let vertex = device.createVertexBuffer(label: "Shadow Geometry Pool Vertices", length: vertexCapacity, binding: 0) + let zeros = [UInt8](repeating: 0, count: indexCapacity) + guard let indexBuffer = unsafe zeros.withUnsafeBytes({ bytes in + unsafe bytes.baseAddress.map { unsafe device.createIndexBuffer(label: "Shadow Geometry Pool Indices", format: .uInt32, bytes: $0, length: indexCapacity) } + }) else { continue } + pools[index].vertex = vertex + pools[index].index = indexBuffer + poolAllocations += 1 + var vertexUsed = 0 + var indexUsed = 0 + for key in pools[index].parts.keys { + guard var part = pools[index].parts[key] else { continue } + part.vertexOffset = (vertexUsed + stride - 1) / stride * stride + part.indexOffset = indexUsed + vertexUsed = part.vertexOffset + part.vertexBytes + indexUsed = part.indexOffset + part.indexBytes + copies.append(Copy(source: part.vertexSource, destination: vertex, destinationOffset: part.vertexOffset, size: part.vertexBytes)) + copies.append(Copy(source: part.indexSource, destination: indexBuffer, destinationOffset: part.indexOffset, size: part.indexBytes)) + pools[index].parts[key] = part + } + pools[index].vertexUsed = vertexUsed + pools[index].indexUsed = indexUsed + pools[index].needsRebuild = false + rebuilt = true + } + if rebuilt { + // Drop copies into buffers that are no longer any pool's buffers. + let live = Set(pools.flatMap { [$0.vertex.map { ObjectIdentifier($0) }, $0.index.map { ObjectIdentifier($0) }] }.compactMap { $0 }) + copies.removeAll { !live.contains(ObjectIdentifier($0.destination)) } + } + return rebuilt + } +} diff --git a/Sources/AdaCorePipelines/CorePipelines/Temporal3DHistory.swift b/Sources/AdaCorePipelines/CorePipelines/Temporal3DHistory.swift index ed7d5d6a5..009d9105f 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Temporal3DHistory.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Temporal3DHistory.swift @@ -133,6 +133,7 @@ struct PrepareTemporal3DSystem { target.temporalReset = decision.reset || !entry.encoded entry.encoded = false target.temporalUpscalingActive = true + target.rayReconstructionAvailable = entry.scaler?.supportsDenoising == true if target.temporalInputTexture?.size != input.size || target.temporalInputTexture?.pixelFormat != .rgba_16f { target.temporalInputTexture = RenderTexture( size: input.size, diff --git a/Sources/AdaECS/Scheduler.swift b/Sources/AdaECS/Scheduler.swift index 3f36b96dd..963be87f3 100644 --- a/Sources/AdaECS/Scheduler.swift +++ b/Sources/AdaECS/Scheduler.swift @@ -225,7 +225,9 @@ public struct DefaultSchedulerRunner: Sendable { } let order = order?.order ?? [] for scheduler in order { + let stall = PerformanceStallProbe.begin() await world.runScheduler(scheduler) + if stall != nil { PerformanceStallProbe.end("Schedule.\(world.name ?? "world").\(scheduler.rawValue)", since: stall) } } } } diff --git a/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md b/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md index 0471ef04e..4d6b7a935 100644 --- a/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md +++ b/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md @@ -41,5 +41,6 @@ For more details and guides, check out the tutorials below. - - - +- - - diff --git a/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/PipelinedRendering.md b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/PipelinedRendering.md new file mode 100644 index 000000000..8be814054 --- /dev/null +++ b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/PipelinedRendering.md @@ -0,0 +1,55 @@ +# Pipelined Rendering + +Render a scene one frame behind its simulation to overlap rendering with the next frame's work. + +## Overview + +By default a frame runs every world in sequence: the scene simulates, extracts its renderable state, +renders, and only then can the window's UI and the next frame start. With pipelined rendering, the +scene's render world draws frame N while the simulation and UI already prepare frame N+1. Extraction is +the only point where the two meet: before extracting frame N+1, the engine waits for frame N to finish. + +A frame then takes about as long as the slower of the two paths instead of their sum. The cost is one +frame of input latency, about 7 ms on a 143 Hz display. Pipelining does not reduce GPU time: when the GPU +is already the limit, the frame rate does not change. + +Enable it for a `SceneView`: + +```swift +SceneView(useSceneCameras: true, make: { app in + app.addPlugin(RenderWorldPlugin()) + // ... +}, updateContent: { _, _ in }) +.pipelinedRendering() +``` + +Or for any render world you manage yourself: + +```swift +renderWorld.insertResource(RenderPipelining(isEnabled: true)) +``` + +Set `ADAENGINE_PIPELINED_RENDERING=0` or `=1` to override the setting of every world that declares it, +for example to compare both modes. WebAssembly builds always render serially. + +## Rules for scene code + +While a frame is in flight, the simulation and the renderer run at the same time. Two rules keep them +apart: + +- **Render systems read the scene only during extraction.** Use `Extract` parameters only in systems + registered on the `.extract` schedule, and copy what later schedules need into render-world resources. + The `MainWorld` reference is removed after extraction; debug builds assert when `Extract` is read later. +- **Do not mutate assets in place while they are rendered.** The renderer draws a snapshot of each + `PBRMaterial` taken at extraction, so material edits are safe and appear one frame later. Other assets + such as meshes and textures should be replaced, not edited in place, while a frame may be rendering them. + +Tooling entry points such as `AppWorlds.withWorldAccess(_:)` wait for the frame in flight before they run. +Render statistics resources are synchronized and can be read at any time. + +## Measuring + +Run with `ADAENGINE_STALL_PROBE=1` to print slow operations, including `App.Update.*` and `Schedule.*` +spans for each world. With pipelining on, the scene's update on the main thread shrinks to its simulation +and extraction while its render world appears as a separate span. The Sponza technical example also +prints per-second frame statistics with `ADAENGINE_SPONZA_FRAME_LOG=1`. diff --git a/Sources/AdaEngine/SceneView/SceneView.swift b/Sources/AdaEngine/SceneView/SceneView.swift index a2bb0d3a8..2f64dce05 100644 --- a/Sources/AdaEngine/SceneView/SceneView.swift +++ b/Sources/AdaEngine/SceneView/SceneView.swift @@ -46,6 +46,7 @@ public struct SceneView: View { let placeholder: @MainActor () -> Placeholder private var prepareFramePresentation: (@MainActor () -> (@MainActor () -> Void))? private var frameCaptureLabel: String? + private var pipelinedRendering = false /// Makes completed frames of this scene readable through ``SceneViewFrameRegistry``. /// @@ -57,6 +58,18 @@ public struct SceneView: View { return view } + /// Renders this scene one frame behind its simulation, overlapping the scene's rendering with the + /// next frame's simulation and the window's UI. Frames take about as long as the slower of the two + /// instead of their sum; input reaches the screen one frame later. + /// + /// Scene systems must follow the pipelined-rendering rules: render systems read the scene only + /// during extraction, and gameplay code does not mutate assets in place while they are rendered. + public func pipelinedRendering(_ isEnabled: Bool = true) -> Self { + var view = self + view.pipelinedRendering = isEnabled + return view + } + /// Captures companion UI state for a scheduled scene frame. The returned /// closure runs when that exact frame becomes the displayed texture, before /// UI drawing. Frames discarded by resize or shutdown are never presented. @@ -106,14 +119,15 @@ public struct SceneView: View { public var body: some View { OffscreenViewportContainer( - delegateFactory: { [make, updateContent, onFrameRendered, useSceneCameras, prepareFramePresentation, frameCaptureLabel] in + delegateFactory: { [make, updateContent, onFrameRendered, useSceneCameras, prepareFramePresentation, frameCaptureLabel, pipelinedRendering] in SceneViewCoordinator( useSceneCameras: useSceneCameras, make: make, updateContent: updateContent, onFrameRendered: onFrameRendered, prepareFramePresentation: prepareFramePresentation, - frameCaptureLabel: frameCaptureLabel + frameCaptureLabel: frameCaptureLabel, + pipelinedRendering: pipelinedRendering ) }, contentBuilder: { [placeholder, isInteractive, updatesWhenWindowInactive] delegate in diff --git a/Sources/AdaEngine/SceneView/SceneViewCoordinator.swift b/Sources/AdaEngine/SceneView/SceneViewCoordinator.swift index 1ad9a0521..720a88bb7 100644 --- a/Sources/AdaEngine/SceneView/SceneViewCoordinator.swift +++ b/Sources/AdaEngine/SceneView/SceneViewCoordinator.swift @@ -46,6 +46,7 @@ final class SceneViewCoordinator: OffscreenViewportDelegate { private(set) var standaloneTickInFlight = false private let hostSubworldName: AppWorldName + private let pipelinedRendering: Bool private var currentSize: SizeInt = .zero private var scaleFactor: Float = 1 private var pendingSize: SizeInt? @@ -85,8 +86,10 @@ final class SceneViewCoordinator: OffscreenViewportDelegate { onFrameRendered: (@MainActor (Texture2D) -> Void)? = nil, prepareFramePresentation: (@MainActor () -> (@MainActor () -> Void))? = nil, frameCaptureLabel: String? = nil, + pipelinedRendering: Bool = false, joinsHostSession: Bool = true ) { + self.pipelinedRendering = pipelinedRendering self.joinsHostSession = joinsHostSession self.frameCaptureLabel = frameCaptureLabel self.useSceneCameras = useSceneCameras @@ -446,9 +449,15 @@ final class SceneViewCoordinator: OffscreenViewportDelegate { } } + /// Steady state holds the front texture, `maxFramesInFlight` retired fronts that the window may + /// still sample, and up to `maxFramesInFlight` frames on the GPU. A smaller pool leaves no free + /// target while the previous frame renders, which skips the frame and serializes CPU and GPU. + static var renderTexturePoolSize: Int { + 1 + 2 * max(1, unsafe RenderEngine.configurations.maxFramesInFlight) + } + private func rebuildRenderTexturePool(size: SizeInt, scaleFactor: Float) { - let poolSize = max(3, unsafe RenderEngine.configurations.maxFramesInFlight + 2) - renderTexturePool = (0.. private var target + + var queries: SystemQueries { SystemQueries(queries: [_target]) } + + init(world _: World) {} + + @MainActor + func update(context _: UpdateContext) { + target.coordinator?.finishRenderSubmission { _ in false } + } + } + @MainActor private final class ContentUpdateTarget: Resource { weak var coordinator: SceneViewCoordinator? diff --git a/Sources/AdaRender/Backends/CommandEncoder.swift b/Sources/AdaRender/Backends/CommandEncoder.swift index 7dad0c405..8eca47fc6 100644 --- a/Sources/AdaRender/Backends/CommandEncoder.swift +++ b/Sources/AdaRender/Backends/CommandEncoder.swift @@ -478,8 +478,23 @@ public protocol RenderCommandEncoder: CommonCommandEncoder { @discardableResult func drawIndexedIndirect(arguments: any Buffer, offset: Int) -> Bool + /// Encodes `drawCount` consecutive GPU-generated indexed draws in one call. Returns false when unsupported. + @discardableResult + func multiDrawIndexedIndirect(arguments: any Buffer, offset: Int, drawCount: Int) -> Bool + + /// Encodes up to `maxDrawCount` consecutive GPU-generated indexed draws; the GPU reads the actual number, a + /// `UInt32`, from `drawCountBuffer` at `drawCountOffset`. Returns false without encoding when unsupported. + @discardableResult + func multiDrawIndexedIndirect(arguments: any Buffer, offset: Int, maxDrawCount: Int, drawCountBuffer: any Buffer, drawCountOffset: Int) -> Bool + func drawIndexed(indexCount: Int, indexBufferOffset: Int, instanceCount: Int) + /// Issues an indexed draw whose per-instance vertex data starts at `firstInstance`. + /// + /// Lets consecutive batches share one bound instance buffer instead of rebinding it at a + /// new offset for every draw. + func drawIndexed(indexCount: Int, indexBufferOffset: Int, instanceCount: Int, firstInstance: Int) + /// Issues a non-indexed draw call. /// /// Draws primitives using sequential vertices from the currently bound vertex buffers. @@ -523,4 +538,16 @@ extension RenderCommandEncoder { extension RenderCommandEncoder { public func drawIndexedIndirect(arguments _: any Buffer, offset _: Int) -> Bool { false } + + /// Issues `drawCount` consecutive ``IndexedIndirectArguments`` starting at `offset`. + /// Returns `false` without encoding anything when the backend has no multi-draw support. + public func multiDrawIndexedIndirect(arguments _: any Buffer, offset _: Int, drawCount _: Int) -> Bool { false } + + public func multiDrawIndexedIndirect( + arguments _: any Buffer, + offset _: Int, + maxDrawCount _: Int, + drawCountBuffer _: any Buffer, + drawCountOffset _: Int + ) -> Bool { false } } diff --git a/Sources/AdaRender/Backends/Headless/HeadlessRenderBackend.swift b/Sources/AdaRender/Backends/Headless/HeadlessRenderBackend.swift index 34c066ee0..aa067aa88 100644 --- a/Sources/AdaRender/Backends/Headless/HeadlessRenderBackend.swift +++ b/Sources/AdaRender/Backends/Headless/HeadlessRenderBackend.swift @@ -303,6 +303,7 @@ private final class HeadlessRenderCommandEncoder: HeadlessCommonCommandEncoder, func setTriangleFillMode(_: TriangleFillMode) {} func setIndexBuffer(_: IndexBuffer, offset _: Int) {} func drawIndexed(indexCount _: Int, indexBufferOffset _: Int, instanceCount _: Int) {} + func drawIndexed(indexCount _: Int, indexBufferOffset _: Int, instanceCount _: Int, firstInstance _: Int) {} func draw(type _: IndexPrimitive, vertexStart _: Int, vertexCount _: Int, instanceCount _: Int) {} func endRenderPass() {} } diff --git a/Sources/AdaRender/Backends/Metal/Misc/MetalRenderCommandEncoder.swift b/Sources/AdaRender/Backends/Metal/Misc/MetalRenderCommandEncoder.swift index a4d9c3099..4db0f666f 100644 --- a/Sources/AdaRender/Backends/Metal/Misc/MetalRenderCommandEncoder.swift +++ b/Sources/AdaRender/Backends/Metal/Misc/MetalRenderCommandEncoder.swift @@ -201,6 +201,22 @@ self.currentIndexType = (metalIndexBuffer.indexFormat == .uInt32) ? .uint32 : .uint16 } + func drawIndexed(indexCount: Int, indexBufferOffset: Int, instanceCount: Int, firstInstance: Int) { + guard let indexBuffer = self.currentIndexBuffer else { + fatalError("Index buffer is not set. Call setIndexBuffer(_:offset:) before drawIndexed().") + } + renderEncoder.drawIndexedPrimitives( + type: currentPrimitiveType, + indexCount: indexCount, + indexType: self.currentIndexType, + indexBuffer: indexBuffer, + indexBufferOffset: indexBufferOffset, + instanceCount: instanceCount, + baseVertex: 0, + baseInstance: firstInstance + ) + } + func drawIndexed(indexCount: Int, indexBufferOffset: Int, instanceCount: Int) { guard let indexBuffer = self.currentIndexBuffer else { fatalError("Index buffer is not set. Call setIndexBuffer(_:offset:) before drawIndexed().") diff --git a/Sources/AdaRender/Backends/RenderBackend.swift b/Sources/AdaRender/Backends/RenderBackend.swift index 2eae1ad56..40bb7015d 100644 --- a/Sources/AdaRender/Backends/RenderBackend.swift +++ b/Sources/AdaRender/Backends/RenderBackend.swift @@ -74,6 +74,23 @@ public protocol RenderDevice: AnyObject, Sendable { /// Creates independent per-view meshlet frustum/compaction resources, or nil on unsupported backends. func createMeshletVisibilityProcessor() -> (any MeshletVisibilityProcessor)? + /// Creates GPU frustum culling for shadow views, or nil to keep CPU-culled shadow lists. + func createGPUShadowCullProcessor() -> (any GPUShadowCullProcessor)? + + /// Whether ``createGPUShadowCullProcessor()`` returns a processor; otherwise shadows stay CPU-culled. + var supportsGPUShadowCull: Bool { get } + + /// Creates GPU meshlet-cluster culling for shadow views, or nil to keep whole-part shadow culling. + func createGPUShadowClusterCullProcessor() -> (any GPUShadowClusterCullProcessor)? + + /// Whether ``createGPUShadowClusterCullProcessor()`` returns a processor and render passes support + /// ``RenderCommandEncoder/multiDrawIndexedIndirect(arguments:offset:maxDrawCount:drawCountBuffer:drawCountOffset:)``. + var supportsGPUShadowClusterCull: Bool { get } + + /// Whether render passes can issue ``RenderCommandEncoder/multiDrawIndexedIndirect(arguments:offset:drawCount:)`` + /// with a non-zero `firstInstance` in the indirect arguments. + var supportsMultiDrawIndirect: Bool { get } + // MARK: - Buffers /// Create a new GPU buffer with specific length and options. @@ -141,6 +158,11 @@ extension RenderDevice { public func createGPUVisibilityProcessor() -> (any GPUVisibilityProcessor)? { nil } public func createMeshletVisibilityProcessor() -> (any MeshletVisibilityProcessor)? { nil } + public func createGPUShadowCullProcessor() -> (any GPUShadowCullProcessor)? { nil } + public var supportsGPUShadowCull: Bool { false } + public func createGPUShadowClusterCullProcessor() -> (any GPUShadowClusterCullProcessor)? { nil } + public var supportsGPUShadowClusterCull: Bool { false } + public var supportsMultiDrawIndirect: Bool { false } public var supportsSpatialUpscaling: Bool { false } public var supportsTemporalUpscaling: Bool { false } public var supportsHybridReflections: Bool { false } diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift index d2221e9c2..4ef3552ab 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUCommandEncoder.swift @@ -25,25 +25,61 @@ private let spatialScaler: WGPUFSRSpatialScaler private let instance: WebGPU.GPUInstance? private var completedHandlers: [@Sendable () -> Void] = [] + private var timingHandlers: [@Sendable (Double?) -> Void] = [] + private var firstPassLabel: String? + #if !WASM + private let timestampQuery: WGPUTimestampQuery? + #endif init(device: WebGPU.GPUDevice, spatialScaler: WGPUFSRSpatialScaler, instance: WebGPU.GPUInstance? = nil) { self.spatialScaler = spatialScaler self.device = device self.instance = instance - self.commandEncoder = webGPUDeviceLock.withLock { _ in + let encoder = webGPUDeviceLock.withLock { _ in device.createCommandEncoder(descriptor: nil) } + self.commandEncoder = encoder + #if !WASM + self.timestampQuery = WGPUTimestampQuery.isRequested + ? webGPUDeviceLock.withLock { _ in WGPUTimestampQuery(device: device, encoder: encoder, instance: instance) } + : nil + #endif } func commit() { let submitStart = PerformanceStallProbe.begin() defer { PerformanceStallProbe.end("WGPU.SubmitCPU", since: submitStart) } + #if !WASM + // Profiling resolves every buffer so the ledger also covers passes without timing handlers. + let timing = timestampQuery + let ledgerName = label ?? firstPassLabel ?? "unlabeled" + #endif let commandBuffer: WebGPU.GPUCommandBuffer = webGPUDeviceLock.withLock { _ in - commandEncoder.finish(descriptor: nil as WebGPU.GPUCommandBufferDescriptor?) + #if !WASM + timing?.encodeEnd(on: commandEncoder) + #endif + return commandEncoder.finish(descriptor: nil as WebGPU.GPUCommandBufferDescriptor?) } WGPUSubmissionEpoch.advance() var handlers = completedHandlers completedHandlers.removeAll() + let timed = timingHandlers + timingHandlers.removeAll() + #if WASM + if !timed.isEmpty { handlers.append { timed.forEach { $0(nil) } } } + #else + if let timing { + handlers.append { + Task.detached { + let seconds = await timing.readElapsedSeconds() + if let seconds { WGPUGPUTimeLedger.record(ledgerName, seconds: seconds) } + timed.forEach { $0(seconds) } + } + } + } else if !timed.isEmpty { + handlers.append { timed.forEach { $0(nil) } } + } + #endif if let submitted = PerformanceStallProbe.begin() { handlers.append { PerformanceStallProbe.end("WGPU.CompletionLatency", since: submitted) } } @@ -83,6 +119,12 @@ completedHandlers.append(handler) } + /// Reports GPU time from this buffer's creation to its commit when `ADAENGINE_GPU_TIMINGS=1` + /// and the device supports timestamp queries; otherwise reports nil. + func addCompletedTimingHandler(_ handler: @escaping @Sendable (Double?) -> Void) { + timingHandlers.append(handler) + } + /// Ends this command segment before an external GPU pass, then resumes /// encoding. Completion handlers cover every segment and the external pass. func withExternalGPUWork(_ work: () -> Bool) -> Bool { @@ -96,6 +138,7 @@ } func beginRenderPass(_ desc: RenderPassDescriptor) -> RenderCommandEncoder { + if firstPassLabel == nil { firstPassLabel = desc.label } var wgpuAttachment: WebGPU.GPURenderPassDepthStencilAttachment? if let depthStencilAttachment = desc.depthStencilAttachment { let view = (depthStencilAttachment.texture.gpuTexture as! WGPUGPUTexture).textureView diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUDLSSUpscaler.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUDLSSUpscaler.swift index d408ba254..6a17e2a36 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUDLSSUpscaler.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUDLSSUpscaler.swift @@ -12,6 +12,8 @@ let handle: OpaquePointer private let available = Mutex(true) var isAvailable: Bool { available.withLock { $0 } } + /// NGX reports DLSS Ray Reconstruction on this adapter and driver. + var supportsRayReconstruction: Bool { unsafe ada_dlss_device_supports_ray_reconstruction(handle) } init?(device: WebGPU.GPUDevice) { guard ProcessInfo.processInfo.environment["ADAENGINE_DISABLE_DLSS"] != "1" else { return nil } @@ -45,6 +47,13 @@ @safe final class WGPUDLSSUpscaler: TemporalUpscaler, @unchecked Sendable { private let device: WGPUDLSSDevice private let handle: OpaquePointer + private let inputSize: SizeInt + private let outputSize: SizeInt + /// Ray Reconstruction view, created on first use; its NGX feature is separate from Super Resolution's. + private let rayReconstruction = Mutex(nil) + private let rayReconstructionFailed = Mutex(false) + + var supportsDenoising: Bool { device.supportsRayReconstruction && !rayReconstructionFailed.withLock { $0 } } init?(device: WGPUDLSSDevice, inputSize: SizeInt, outputSize: SizeInt) { guard inputSize.width > 0, inputSize.height > 0, @@ -58,10 +67,68 @@ guard let created else { return nil } self.device = device self.handle = created + self.inputSize = inputSize + self.outputSize = outputSize } deinit { - webGPUDeviceLock.withLock { _ in unsafe ada_dlss_view_destroy(handle) } + let denoiser = rayReconstruction.withLock { $0 } + webGPUDeviceLock.withLock { _ in + unsafe ada_dlss_view_destroy(handle) + if let denoiser { unsafe ada_dlss_view_destroy(denoiser) } + } + } + + func encodeDenoised(commandBuffer: CommandBuffer, color: Texture, depth: Texture, motion: Texture, reactive: Texture, + guides: RayReconstructionGuides, output: Texture, jitter: Vector2, reset: Bool) -> Bool { + guard supportsDenoising, + let command = commandBuffer as? WGPUCommandEncoder, + let color = color.gpuTexture as? WGPUGPUTexture, + let depth = depth.gpuTexture as? WGPUGPUTexture, + let motion = motion.gpuTexture as? WGPUGPUTexture, + let reactive = reactive.gpuTexture as? WGPUGPUTexture, + let diffuse = guides.diffuseAlbedo.gpuTexture as? WGPUGPUTexture, + let specular = guides.specularAlbedo.gpuTexture as? WGPUGPUTexture, + let normals = guides.normalRoughness.gpuTexture as? WGPUGPUTexture, + let output = output.gpuTexture as? WGPUGPUTexture, + let width = UInt32(exactly: inputSize.width), let height = UInt32(exactly: inputSize.height), + let outputWidth = UInt32(exactly: outputSize.width), let outputHeight = UInt32(exactly: outputSize.height) + else { return false } + let view: OpaquePointer? = rayReconstruction.withLock { view in + if view == nil { + view = webGPUDeviceLock.withLock { _ in + unsafe ada_dlss_rr_view_create(device.handle, width, height, outputWidth, outputHeight) + } + } + return view + } + guard let view else { + rayReconstructionFailed.withLock { $0 = true } + return false + } + // NGX takes row-major matrices for row vectors, which is the column-major layout of these transforms. + let worldToView = Self.floats(guides.worldToView) + let viewToClip = Self.floats(guides.viewToClip) + let encoded = command.withExternalGPUWork { + unsafe worldToView.withUnsafeBufferPointer { worldToView in + unsafe viewToClip.withUnsafeBufferPointer { viewToClip in + unsafe ada_dlss_rr_evaluate(view, native(color.texture), native(depth.texture), native(motion.texture), + native(reactive.texture), native(diffuse.texture), native(specular.texture), native(normals.texture), + native(output.texture), jitter.x, jitter.y, worldToView.baseAddress, viewToClip.baseAddress, reset) + } + } + } + if !encoded { + rayReconstructionFailed.withLock { $0 = true } + let reason = unsafe String(cString: ada_dlss_last_error()) + FileHandle.standardError.write(Data("AdaEngine DLSS Ray Reconstruction disabled: \(reason)\n".utf8)) + } + return encoded + } + + private static func floats(_ matrix: Transform3D) -> [Float] { + [matrix.x.x, matrix.x.y, matrix.x.z, matrix.x.w, matrix.y.x, matrix.y.y, matrix.y.z, matrix.y.w, + matrix.z.x, matrix.z.y, matrix.z.z, matrix.z.w, matrix.w.x, matrix.w.y, matrix.w.z, matrix.w.w] } func encode(commandBuffer: CommandBuffer, color: Texture, depth: Texture, motion: Texture, diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift index 35a707205..5f9f9f626 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift @@ -17,6 +17,9 @@ let renderEncoder: WebGPU.GPURenderPassEncoder private var currentIndexBuffer: WebGPU.GPUBuffer? private var currentIndexType: WebGPU.GPUIndexFormat = .uint32 + private var currentIndexOwner: WGPUIndexBuffer? + private var currentIndexOffset: UInt64 = 0 + private var currentIndexSize: UInt64 = 0 private var currentPipeline: WGPURenderPipeline? private var genericBuffers: [Int: (buffer: WGPUBuffer, offset: UInt64, size: UInt64)] = [:] private var vertexBuffers: [Int: (buffer: WGPUBuffer, offset: UInt64, size: UInt64)] = [:] @@ -186,6 +189,9 @@ } currentIndexBuffer = wgpuBuffer.buffer currentIndexType = indexFormat == .uInt32 ? .uint32 : .uint16 + currentIndexOwner = nil + currentIndexOffset = 0 + currentIndexSize = UInt64(wgpuBuffer.length) renderEncoder.setIndexBuffer( buffer: wgpuBuffer.buffer, format: currentIndexType, @@ -321,8 +327,18 @@ guard let wgpuIndexBuffer = buffer as? WGPUIndexBuffer else { fatalError("IndexBuffer is not a WGPUIndexBuffer") } + let indexType: WebGPU.GPUIndexFormat = (wgpuIndexBuffer.indexFormat == .uInt32) ? .uint32 : .uint16 + let size = UInt64(buffer.length - offset) + // Consecutive draws of one mesh part share its index buffer; Dawn validates every rebind. + if currentIndexOwner === wgpuIndexBuffer, currentIndexBuffer != nil, currentIndexType == indexType, + currentIndexOffset == UInt64(offset), currentIndexSize == size { + return + } self.currentIndexBuffer = wgpuIndexBuffer.buffer - self.currentIndexType = (wgpuIndexBuffer.indexFormat == .uInt32) ? .uint32 : .uint16 + self.currentIndexOwner = wgpuIndexBuffer + self.currentIndexType = indexType + self.currentIndexOffset = UInt64(offset) + self.currentIndexSize = size renderEncoder.setIndexBuffer( buffer: wgpuIndexBuffer.buffer, format: currentIndexType, @@ -332,6 +348,10 @@ } func drawIndexed(indexCount: Int, indexBufferOffset: Int, instanceCount: Int) { + drawIndexed(indexCount: indexCount, indexBufferOffset: indexBufferOffset, instanceCount: instanceCount, firstInstance: 0) + } + + func drawIndexed(indexCount: Int, indexBufferOffset: Int, instanceCount: Int, firstInstance: Int) { guard currentIndexBuffer != nil else { fatalError("Index buffer is not set. Call setIndexBuffer(_:offset:) before drawIndexed().") } @@ -346,7 +366,7 @@ instanceCount: UInt32(instanceCount), firstIndex: UInt32(indexBufferOffset / (currentIndexType == .uint32 ? 4 : 2)), baseVertex: 0, - firstInstance: 0 + firstInstance: UInt32(firstInstance) ) } @@ -367,6 +387,49 @@ return true } + func multiDrawIndexedIndirect(arguments: any Buffer, offset: Int, drawCount: Int) -> Bool { + #if WASM + return false + #else + let stride = MemoryLayout.stride + guard drawCount > 0, let arguments = arguments as? WGPUBuffer, currentIndexBuffer != nil, + offset >= 0, offset % 4 == 0, offset + drawCount * stride <= arguments.length, + device.hasFeature(feature: .multiDrawIndirect) + else { return false } + if bindGroupDirty { commitBindGroup() } + renderEncoder.multiDrawIndexedIndirect( + indirectBuffer: arguments.buffer, + indirectOffset: UInt64(offset), + maxDrawCount: UInt32(drawCount), + drawCountBuffer: nil, + drawCountBufferOffset: 0 + ) + return true + #endif + } + + func multiDrawIndexedIndirect(arguments: any Buffer, offset: Int, maxDrawCount: Int, drawCountBuffer: any Buffer, drawCountOffset: Int) -> Bool { + #if WASM + return false + #else + let stride = MemoryLayout.stride + guard maxDrawCount > 0, let arguments = arguments as? WGPUBuffer, let counts = drawCountBuffer as? WGPUBuffer, + currentIndexBuffer != nil, offset >= 0, offset % 4 == 0, offset + maxDrawCount * stride <= arguments.length, + drawCountOffset >= 0, drawCountOffset % 4 == 0, drawCountOffset + 4 <= counts.length, + device.hasFeature(feature: .multiDrawIndirect) + else { return false } + if bindGroupDirty { commitBindGroup() } + renderEncoder.multiDrawIndexedIndirect( + indirectBuffer: arguments.buffer, + indirectOffset: UInt64(offset), + maxDrawCount: UInt32(maxDrawCount), + drawCountBuffer: counts.buffer, + drawCountBufferOffset: UInt64(drawCountOffset) + ) + return true + #endif + } + func draw(type _: IndexPrimitive, vertexStart: Int, vertexCount: Int, instanceCount: Int) { // Ensure bind groups are committed before drawing if bindGroupDirty { diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowClusterCullProcessor.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowClusterCullProcessor.swift new file mode 100644 index 000000000..a29d08496 --- /dev/null +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowClusterCullProcessor.swift @@ -0,0 +1,280 @@ +#if WEBGPU_ENABLED && canImport(WebGPU) && !WASM + import Foundation + import Math + import Synchronization + @unsafe @preconcurrency import WebGPU + + enum WGPUShadowClusterCullShaders { + /// Consecutive visible clusters merged into one draw, at most. Clusters tile their part's index range + /// in order, so a run of them is one contiguous index range. Merging keeps the number of indirect draws + /// small: Dawn's D3D12 multi-draw stops the queue with tens of thousands of draws per call. + static let maximumRun: UInt32 = 1024 + + /// `classify`: one thread per (view, cluster) writes its visibility, with the test of `WGPUShadowCullShaders.cull`. + /// `emit`: a visible cluster that starts a run (its part's first cluster, after an invisible one, or at a + /// multiple of `maximumRun`) writes one draw covering the run, compacted per (view, part) with a counter. + static let cull = """ + struct Cluster { minimum: vec4f, maximum: vec4f, firstIndex: u32, indexCount: u32, part: u32, instance: u32 } + struct View { planes: array, culls: u32, pad0: u32, pad1: u32, pad2: u32 } + struct Params { clusterCount: u32, viewCount: u32, partCount: u32, pad0: u32 } + struct Draw { indexCount: u32, instanceCount: u32, firstIndex: u32, baseVertex: i32, firstInstance: u32 } + const maximumRun = \(maximumRun)u; + @group(0) @binding(0) var clusters: array; + @group(0) @binding(1) var views: array; + @group(0) @binding(2) var segments: array; + @group(0) @binding(3) var draws: array; + @group(0) @binding(4) var counts: array>; + @group(0) @binding(5) var params: Params; + @group(0) @binding(6) var visibility: array; + fn finite(v: vec4f) -> bool { + return all((bitcast(v) & vec4u(0x7fffffffu)) < vec4u(0x7f800000u)); + } + fn visible(c: Cluster, view: View) -> bool { + if (view.culls == 0u) { return true; } + let center = (c.minimum.xyz + c.maximum.xyz) * 0.5; + let half = (c.maximum.xyz - c.minimum.xyz) * 0.5; + if (!finite(vec4f(center, 1.0)) || !finite(vec4f(half, 1.0))) { return true; } + for (var i = 0u; i < 6u; i++) { + let plane = view.planes[i]; + let radius = dot(abs(plane.xyz), half); + let distance = dot(plane.xyz, center) + plane.w + radius; + let scale = dot(abs(plane.xyz), abs(center) + half) + abs(plane.w); + if (!finite(vec4f(distance, scale, 0.0, 1.0))) { return true; } + if (distance < -0.0001 - scale * 0.00001) { return false; } + } + return true; + } + fn threadIndex(id: vec3u, groups: vec3u) -> u32 { return id.x + id.y * groups.x * 64u; } + @compute @workgroup_size(64) + fn classify(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) groups: vec3u) { + let i = threadIndex(id, groups); + if (i >= params.clusterCount * params.viewCount) { return; } + let v = i / params.clusterCount; + visibility[i] = select(0u, 1u, visible(clusters[i % params.clusterCount], views[v])); + } + @compute @workgroup_size(64) + fn emit(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) groups: vec3u) { + let i = threadIndex(id, groups); + if (i >= params.clusterCount * params.viewCount || visibility[i] == 0u) { return; } + let v = i / params.clusterCount; + let index = i % params.clusterCount; + let c = clusters[index]; + let first = segments[c.part]; + let local = index - first; + if (local % maximumRun != 0u && visibility[i - 1u] != 0u) { return; } + // The part's clusters end at the next part's first cluster, or at the end of the table. + var end = params.clusterCount; + if (c.part + 1u < params.partCount) { end = segments[c.part + 1u]; } + var last = index; + for (var k = 1u; k < maximumRun; k++) { + let next = index + k; + if (next >= end || visibility[i + k] == 0u) { break; } + last = next; + } + let tail = clusters[last]; + let slot = atomicAdd(&counts[v * params.partCount + c.part], 1u); + draws[v * params.clusterCount + first + slot] = Draw(tail.firstIndex + tail.indexCount - c.firstIndex, 1u, c.firstIndex, 0, c.instance); + } + """ + } + + /// One render node and camera own this state. The lock protects its frame ring; GPU calls use the device lock. + final class WGPUShadowClusterCullProcessor: GPUShadowClusterCullProcessor, @unchecked Sendable { + private struct Params { + var clusterCount: UInt32 + var viewCount: UInt32 + var partCount: UInt32 + var padding: UInt32 = 0 + } + private struct Frame { + var clusters: WebGPU.GPUBuffer + var clusterBytes: Int + var views: WebGPU.GPUBuffer + var viewBytes: Int + var segments: WebGPU.GPUBuffer + var segmentBytes: Int + var arguments: WGPUBuffer + var counts: WGPUBuffer + var visibility: WebGPU.GPUBuffer + var visibilityBytes: Int + var instances: WGPUBuffer + var params: WebGPU.GPUBuffer + var group: WebGPU.GPUBindGroup + /// Revision of the input last uploaded into this frame's buffers. + var revision: UInt64? + } + // Immutable vendor handles created under webGPUDeviceLock and only used under it afterwards. + private struct Pipeline: @unchecked Sendable { + let layout: WebGPU.GPUBindGroupLayout + let classify: WebGPU.GPUComputePipeline + let emit: WebGPU.GPUComputePipeline + } + private let lock = NSLock() + private let context: WGPUContext + private let layout: WebGPU.GPUBindGroupLayout + private let classify: WebGPU.GPUComputePipeline + private let emit: WebGPU.GPUComputePipeline + private var frames: [Frame?] + private var frame = 0 + private var validatedRevision: UInt64? + private var isValidatedInputValid = false + + init(context: WGPUContext) { + self.context = context + let device = context.device + let created = webGPUDeviceLock.withLock { _ in + let layout = device.createBindGroupLayout(descriptor: .init(entries: [ + .init(binding: 0, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 1, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 2, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 3, visibility: .compute, buffer: .init(type: .storage)), + .init(binding: 4, visibility: .compute, buffer: .init(type: .storage)), + .init(binding: 5, visibility: .compute, buffer: .init(type: .uniform)), + .init(binding: 6, visibility: .compute, buffer: .init(type: .storage)), + ])) + let shader = device.createShaderModule(descriptor: .init( + label: "Shadow cluster cull", nextInChain: WebGPU.GPUShaderSourceWGSL(code: WGPUShadowClusterCullShaders.cull) + )) + let pipelineLayout = device.createPipelineLayout(descriptor: .init(bindGroupLayouts: [layout])) + func pipeline(_ entry: String) -> WebGPU.GPUComputePipeline { + device.createComputePipeline(descriptor: .init( + label: "Shadow cluster cull \(entry)", layout: pipelineLayout, compute: .init(module: shader, entryPoint: entry) + )) + } + return Pipeline(layout: layout, classify: pipeline("classify"), emit: pipeline("emit")) + } + layout = created.layout + classify = created.classify + emit = created.emit + frames = Array(repeating: nil, count: max(1, unsafe RenderEngine.configurations.maxFramesInFlight)) + } + + func encode(on command: any CommandBuffer, input: GPUShadowClusterInput, views: [GPUShadowCullView]) -> GPUShadowClusterCullOutput? { + guard let command = command as? WGPUCommandEncoder, !views.isEmpty else { return nil } + // Validation walks every cluster: do it once per input revision, not every frame. + let valid = lock.withLock { () -> Bool in + if validatedRevision != input.revision { + validatedRevision = input.revision + isValidatedInputValid = input.isValid + } + return isValidatedInputValid + } + guard valid else { return nil } + let argumentBytes = views.count * input.clusters.count * MemoryLayout.stride + guard argumentBytes <= GPUShadowClusterCullLimits.argumentBytes, views.count * input.clusters.count <= Int(UInt32.max) else { + return nil + } + return lock.withLock { + webGPUDeviceLock.withLock { _ in + encodeLocked(command, input: input, views: views, argumentBytes: argumentBytes) + } + } + } + + private func encodeLocked( + _ command: WGPUCommandEncoder, + input: GPUShadowClusterInput, + views: [GPUShadowCullView], + argumentBytes: Int + ) -> GPUShadowClusterCullOutput? { + frame = (frame + 1) % frames.count + let clusterBytes = input.clusters.count * MemoryLayout.stride + let viewBytes = views.count * MemoryLayout.stride + let segmentBytes = input.segments.count * MemoryLayout.stride + let countBytes = views.count * input.segments.count * MemoryLayout.stride + let visibilityBytes = views.count * input.clusters.count * MemoryLayout.stride + if let current = frames[frame], current.clusterBytes >= clusterBytes, current.viewBytes >= viewBytes, + current.segmentBytes >= segmentBytes, current.arguments.length >= argumentBytes, + current.counts.length >= countBytes, current.visibilityBytes >= visibilityBytes, + current.instances.length >= input.instances.count { + // Reuse. + } else { + frames[frame] = makeFrame( + clusterBytes: clusterBytes, viewBytes: viewBytes, segmentBytes: segmentBytes, + argumentBytes: argumentBytes, countBytes: countBytes, visibilityBytes: visibilityBytes, + instanceBytes: input.instances.count + ) + } + guard var buffers = frames[frame] else { return nil } + if buffers.revision != input.revision { + write(input.clusters, to: buffers.clusters) + write(input.segments, to: buffers.segments) + write(input.instances, to: buffers.instances.buffer) + buffers.revision = input.revision + frames[frame] = buffers + } + write(views, to: buffers.views) + // Counters start at zero; only the first `count` arguments of each segment are read by the draws. + write([UInt32](repeating: 0, count: views.count * input.segments.count), to: buffers.counts.buffer) + var params = Params(clusterCount: UInt32(input.clusters.count), viewCount: UInt32(views.count), partCount: UInt32(input.segments.count)) + unsafe withUnsafeBytes(of: ¶ms) { bytes in + unsafe context.device.queue.writeBuffer(buffer: buffers.params, bufferOffset: 0, data: bytes) + } + let groups = (views.count * input.clusters.count + 63) / 64 + let columns = min(groups, 65_535) + let pass = command.commandEncoder.beginComputePass(descriptor: nil as WebGPU.GPUComputePassDescriptor?) + pass.setBindGroup(groupIndex: 0, group: buffers.group, dynamicOffsets: []) + // WebGPU orders the two dispatches: `emit` reads every visibility `classify` wrote. + for pipeline in [classify, emit] { + pass.setPipeline(pipeline: pipeline) + pass.dispatchWorkgroups(workgroupCountX: UInt32(columns), workgroupCountY: UInt32((groups + columns - 1) / columns), workgroupCountZ: 1) + } + pass.end() + return GPUShadowClusterCullOutput( + arguments: buffers.arguments, + counts: buffers.counts, + instances: buffers.instances, + viewCount: views.count, + input: input + ) + } + + private func makeFrame( + clusterBytes: Int, viewBytes: Int, segmentBytes: Int, argumentBytes: Int, countBytes: Int, visibilityBytes: Int, instanceBytes: Int + ) -> Frame? { + let device = context.device + // Grow geometrically so a slowly growing scene does not reallocate every frame. + func size(_ bytes: Int) -> Int { max(256, (bytes + bytes / 2 + 255) & ~255) } + let clusterSize = size(clusterBytes), viewSize = size(viewBytes), segmentSize = size(segmentBytes) + let argumentSize = min(size(argumentBytes), GPUShadowClusterCullLimits.argumentBytes) + let countSize = size(countBytes), instanceSize = size(instanceBytes), visibilitySize = size(visibilityBytes) + let clusters: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(clusterSize))) + let views: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(viewSize))) + let segments: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(segmentSize))) + let arguments: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .indirect], size: UInt64(argumentSize))) + let counts: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .indirect, .copyDst], size: UInt64(countSize))) + let instances: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.vertex, .copyDst], size: UInt64(instanceSize))) + let visibility: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage], size: UInt64(visibilitySize))) + let params: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.uniform, .copyDst], size: UInt64(MemoryLayout.stride))) + guard let clusters, let views, let segments, let arguments, let counts, let instances, let visibility, let params else { return nil } + let entries: [WebGPU.GPUBindGroupEntry] = [ + .init(binding: 0, buffer: clusters, size: UInt64(clusterSize)), + .init(binding: 1, buffer: views, size: UInt64(viewSize)), + .init(binding: 2, buffer: segments, size: UInt64(segmentSize)), + .init(binding: 3, buffer: arguments, size: UInt64(argumentSize)), + .init(binding: 4, buffer: counts, size: UInt64(countSize)), + .init(binding: 5, buffer: params, size: UInt64(MemoryLayout.stride)), + .init(binding: 6, buffer: visibility, size: UInt64(visibilitySize)), + ] + let group = device.createBindGroup(descriptor: .init(layout: layout, entries: entries)) + return Frame( + clusters: clusters, clusterBytes: clusterSize, + views: views, viewBytes: viewSize, + segments: segments, segmentBytes: segmentSize, + arguments: WGPUBuffer(buffer: arguments, device: device, instance: context.instance), + counts: WGPUBuffer(buffer: counts, device: device, instance: context.instance), + visibility: visibility, visibilityBytes: visibilitySize, + instances: WGPUBuffer(buffer: instances, device: device, instance: context.instance), + params: params, + group: group, + revision: nil + ) + } + + private func write(_ values: [T], to buffer: WebGPU.GPUBuffer) { + unsafe values.withUnsafeBytes { bytes in + unsafe context.device.queue.writeBuffer(buffer: buffer, bufferOffset: 0, data: bytes) + } + } + } +#endif diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullProcessor.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullProcessor.swift new file mode 100644 index 000000000..7be2deb98 --- /dev/null +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullProcessor.swift @@ -0,0 +1,224 @@ +#if WEBGPU_ENABLED && canImport(WebGPU) + import Foundation + import Math + import Synchronization + @unsafe @preconcurrency import WebGPU + + #if WASM + private typealias ShadowCullBinding = WebGPU.GPUBindGroupEntryEx + #else + private typealias ShadowCullBinding = WebGPU.GPUBindGroupEntry + #endif + + /// One render node and camera own this state. The lock protects its frame ring; GPU calls use the device lock. + final class WGPUShadowCullProcessor: GPUShadowCullProcessor, @unchecked Sendable { + private struct Params { + var casterCount: UInt32 + var viewCount: UInt32 + var partCount: UInt32 + var strideWords: UInt32 + var copiedWords: UInt32 + var padding0: UInt32 = 0 + var padding1: UInt32 = 0 + var padding2: UInt32 = 0 + } + private struct Frame { + var casters: WebGPU.GPUBuffer + var casterBytes: Int + var views: WebGPU.GPUBuffer + var viewBytes: Int + var segments: WebGPU.GPUBuffer + var segmentBytes: Int + var input: WebGPU.GPUBuffer + var inputBytes: Int + var output: WGPUBuffer + var arguments: WGPUBuffer + var params: WebGPU.GPUBuffer + var group: WebGPU.GPUBindGroup + /// Revision of the input last uploaded into this frame's buffers. + var revision: UInt64? + } + // Immutable vendor handles created under webGPUDeviceLock and only used under it afterwards. + private struct Pipeline: @unchecked Sendable { + let layout: WebGPU.GPUBindGroupLayout + let pipeline: WebGPU.GPUComputePipeline + } + /// Baseline WebGPU `maxStorageBufferBindingSize`. + private static let bindingLimit = 128 * 1_024 * 1_024 + private let lock = NSLock() + private let context: WGPUContext + private let layout: WebGPU.GPUBindGroupLayout + private let pipeline: WebGPU.GPUComputePipeline + private var frames: [Frame?] + private var frame = 0 + + init(context: WGPUContext) { + self.context = context + let device = context.device + let created = webGPUDeviceLock.withLock { _ in + let layout = device.createBindGroupLayout(descriptor: .init(entries: [ + .init(binding: 0, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 1, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 2, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 3, visibility: .compute, buffer: .init(type: .readOnlyStorage)), + .init(binding: 4, visibility: .compute, buffer: .init(type: .storage)), + .init(binding: 5, visibility: .compute, buffer: .init(type: .storage)), + .init(binding: 6, visibility: .compute, buffer: .init(type: .uniform)), + ])) + #if WASM + let shader = device.createShaderModule(descriptor: .init(label: "Shadow cull", code: WGPUShadowCullShaders.cull)) + #else + let shader = device.createShaderModule(descriptor: .init( + label: "Shadow cull", nextInChain: WebGPU.GPUShaderSourceWGSL(code: WGPUShadowCullShaders.cull) + )) + #endif + let pipelineLayout = device.createPipelineLayout(descriptor: .init(bindGroupLayouts: [layout])) + let pipeline = device.createComputePipeline(descriptor: .init( + label: "Shadow cull", layout: pipelineLayout, compute: .init(module: shader, entryPoint: "main") + )) + return Pipeline(layout: layout, pipeline: pipeline) + } + layout = created.layout + pipeline = created.pipeline + frames = Array(repeating: nil, count: max(1, unsafe RenderEngine.configurations.maxFramesInFlight)) + } + + func encode(on command: any CommandBuffer, input: GPUShadowCullInput, views: [GPUShadowCullView], firstInstance: Bool) -> GPUShadowCullOutput? { + guard let command = command as? WGPUCommandEncoder, !views.isEmpty, input.isValid else { return nil } + let outputBytes = views.count * input.casters.count * input.instanceStride + let argumentBytes = views.count * input.parts.count * MemoryLayout.stride + guard outputBytes <= Self.bindingLimit, argumentBytes <= Self.bindingLimit, input.instances.count <= Self.bindingLimit, + views.count * input.casters.count <= Int(UInt32.max) + else { return nil } + return lock.withLock { + webGPUDeviceLock.withLock { _ in + encodeLocked(command, input: input, views: views, firstInstance: firstInstance, outputBytes: outputBytes, argumentBytes: argumentBytes) + } + } + } + + private func encodeLocked( + _ command: WGPUCommandEncoder, + input: GPUShadowCullInput, + views: [GPUShadowCullView], + firstInstance: Bool, + outputBytes: Int, + argumentBytes: Int + ) -> GPUShadowCullOutput? { + frame = (frame + 1) % frames.count + let casterBytes = input.casters.count * MemoryLayout.stride + let viewBytes = views.count * MemoryLayout.stride + let segmentBytes = input.segments.count * MemoryLayout.stride + if let current = frames[frame], current.casterBytes >= casterBytes, current.viewBytes >= viewBytes, + current.segmentBytes >= segmentBytes, current.inputBytes >= input.instances.count, + current.output.length >= outputBytes, current.arguments.length >= argumentBytes { + // Reuse. + } else { + frames[frame] = makeFrame( + casterBytes: casterBytes, viewBytes: viewBytes, segmentBytes: segmentBytes, + inputBytes: input.instances.count, outputBytes: outputBytes, argumentBytes: argumentBytes + ) + } + guard var buffers = frames[frame] else { return nil } + if buffers.revision != input.revision { + write(input.casters, to: buffers.casters) + write(input.segments, to: buffers.segments) + write(input.instances, to: buffers.input) + buffers.revision = input.revision + frames[frame] = buffers + } + write(views, to: buffers.views) + var arguments: [IndexedIndirectArguments] = [] + arguments.reserveCapacity(views.count * input.parts.count) + for view in views.indices { + for part in input.parts.indices { + let start = view * input.casters.count + Int(input.segments[part]) + arguments.append(IndexedIndirectArguments( + indexCount: input.parts[part].indexCount, + instanceCount: 0, + firstIndex: input.parts[part].firstIndex, + baseVertex: input.parts[part].baseVertex, + firstInstance: firstInstance ? UInt32(start) : 0 + )) + } + } + write(arguments, to: buffers.arguments.buffer) + var params = Params( + casterCount: UInt32(input.casters.count), + viewCount: UInt32(views.count), + partCount: UInt32(input.parts.count), + strideWords: UInt32(input.instanceStride / 4), + copiedWords: UInt32(input.copiedBytes / 4) + ) + unsafe withUnsafeBytes(of: ¶ms) { bytes in + unsafe context.device.queue.writeBuffer(buffer: buffers.params, bufferOffset: 0, data: bytes) + } + let groups = (views.count * input.casters.count + 63) / 64 + let columns = min(groups, 65_535) + let pass = command.commandEncoder.beginComputePass(descriptor: nil as WebGPU.GPUComputePassDescriptor?) + pass.setPipeline(pipeline: pipeline) + pass.setBindGroup(groupIndex: 0, group: buffers.group, dynamicOffsets: []) + pass.dispatchWorkgroups(workgroupCountX: UInt32(columns), workgroupCountY: UInt32((groups + columns - 1) / columns), workgroupCountZ: 1) + pass.end() + return GPUShadowCullOutput( + instances: buffers.output, + arguments: buffers.arguments, + viewCount: views.count, + partCount: input.parts.count, + casterCount: input.casters.count, + instanceStride: input.instanceStride + ) + } + + private func makeFrame(casterBytes: Int, viewBytes: Int, segmentBytes: Int, inputBytes: Int, outputBytes: Int, argumentBytes: Int) -> Frame? { + let device = context.device + // Grow geometrically so a slowly growing scene does not reallocate every frame. + func size(_ bytes: Int) -> Int { max(256, (bytes + bytes / 2 + 255) & ~255) } + let casterSize = size(casterBytes), viewSize = size(viewBytes), segmentSize = size(segmentBytes) + let inputSize = size(inputBytes), outputSize = min(size(outputBytes), Self.bindingLimit), argumentSize = min(size(argumentBytes), Self.bindingLimit) + let casters: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(casterSize))) + let views: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(viewSize))) + let segments: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(segmentSize))) + let input: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .copyDst], size: UInt64(inputSize))) + let output: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init(usage: [.storage, .vertex, .copySrc], size: UInt64(outputSize))) + let arguments: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init( + usage: [.storage, .indirect, .copyDst, .copySrc], size: UInt64(argumentSize) + )) + let params: WebGPU.GPUBuffer? = device.createBuffer(descriptor: .init( + usage: [.uniform, .copyDst], size: UInt64(MemoryLayout.stride) + )) + guard let casters, let views, let segments, let input, let output, let arguments, let params else { return nil } + let entries: [ShadowCullBinding] = [ + .init(binding: 0, buffer: casters, size: UInt64(casterSize)), + .init(binding: 1, buffer: views, size: UInt64(viewSize)), + .init(binding: 2, buffer: segments, size: UInt64(segmentSize)), + .init(binding: 3, buffer: input, size: UInt64(inputSize)), + .init(binding: 4, buffer: output, size: UInt64(outputSize)), + .init(binding: 5, buffer: arguments, size: UInt64(argumentSize)), + .init(binding: 6, buffer: params, size: UInt64(MemoryLayout.stride)), + ] + #if WASM + let group = device.createBindGroup(layout: layout, entries: entries) + #else + let group = device.createBindGroup(descriptor: .init(layout: layout, entries: entries)) + #endif + return Frame( + casters: casters, casterBytes: casterSize, + views: views, viewBytes: viewSize, + segments: segments, segmentBytes: segmentSize, + input: input, inputBytes: inputSize, + output: WGPUBuffer(buffer: output, device: device, instance: context.instance), + arguments: WGPUBuffer(buffer: arguments, device: device, instance: context.instance), + params: params, + group: group, + revision: nil + ) + } + + private func write(_ values: [T], to buffer: WebGPU.GPUBuffer) { + unsafe values.withUnsafeBytes { bytes in + unsafe context.device.queue.writeBuffer(buffer: buffer, bufferOffset: 0, data: bytes) + } + } + } +#endif diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullShaders.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullShaders.swift new file mode 100644 index 000000000..78ddb2edf --- /dev/null +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUShadowCullShaders.swift @@ -0,0 +1,57 @@ +#if WEBGPU_ENABLED && canImport(WebGPU) + enum WGPUShadowCullShaders { + /// One thread per (view, caster). Mirrors `MeshVisibilityFrustum3D.intersects` with a relative tolerance + /// toward drawing, so GPU float differences can only add (harmless) draws, never drop one. + static let cull = """ + struct Caster { minimum: vec4f, maximum: vec4f, part: u32, instance: u32, pad0: u32, pad1: u32 } + struct View { planes: array, culls: u32, pad0: u32, pad1: u32, pad2: u32 } + struct Params { + casterCount: u32, viewCount: u32, partCount: u32, strideWords: u32, + copiedWords: u32, pad0: u32, pad1: u32, pad2: u32, + } + struct Draw { + indexCount: u32, instanceCount: atomic, firstIndex: u32, baseVertex: i32, firstInstance: u32, + } + @group(0) @binding(0) var casters: array; + @group(0) @binding(1) var views: array; + @group(0) @binding(2) var segments: array; + @group(0) @binding(3) var instanceInput: array; + @group(0) @binding(4) var instanceOutput: array; + @group(0) @binding(5) var draws: array; + @group(0) @binding(6) var params: Params; + fn finite(v: vec4f) -> bool { + return all((bitcast(v) & vec4u(0x7fffffffu)) < vec4u(0x7f800000u)); + } + fn visible(c: Caster, view: View) -> bool { + if (view.culls == 0u || c.minimum.w != 0.0) { return true; } + let center = (c.minimum.xyz + c.maximum.xyz) * 0.5; + let half = (c.maximum.xyz - c.minimum.xyz) * 0.5; + if (!finite(vec4f(center, 1.0)) || !finite(vec4f(half, 1.0))) { return true; } + for (var i = 0u; i < 6u; i++) { + let plane = view.planes[i]; + let radius = dot(abs(plane.xyz), half); + let distance = dot(plane.xyz, center) + plane.w + radius; + let scale = dot(abs(plane.xyz), abs(center) + half) + abs(plane.w); + if (!finite(vec4f(distance, scale, 0.0, 1.0))) { return true; } + if (distance < -0.0001 - scale * 0.00001) { return false; } + } + return true; + } + @compute @workgroup_size(64) + fn main(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) groups: vec3u) { + let i = id.x + id.y * groups.x * 64u; + if (i >= params.casterCount * params.viewCount) { return; } + let v = i / params.casterCount; + let c = casters[i % params.casterCount]; + if (!visible(c, views[v])) { return; } + let draw = v * params.partCount + c.part; + let destination = v * params.casterCount + segments[c.part] + atomicAdd(&draws[draw].instanceCount, 1u); + let source = c.instance * params.strideWords; + let output = destination * params.strideWords; + for (var word = 0u; word < params.copiedWords; word++) { + instanceOutput[output + word] = instanceInput[source + word]; + } + } + """ + } +#endif diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUTimestampQuery.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUTimestampQuery.swift new file mode 100644 index 000000000..029b915b4 --- /dev/null +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUTimestampQuery.swift @@ -0,0 +1,149 @@ +// +// WGPUTimestampQuery.swift +// AdaEngine +// + +#if WEBGPU_ENABLED && canImport(WebGPU) && !WASM + import Foundation + import Synchronization + @unsafe @preconcurrency import WebGPU + + /// With `ADAENGINE_GPU_TIMINGS=1`, sums the GPU time of every command buffer by label and prints + /// `GPU_LEDGER` lines every second: milliseconds of GPU work per second of wall time (the share + /// of time the single queue was busy) and the split by label. + enum WGPUGPUTimeLedger { + private struct Entry { + var count = 0 + var sum: Double = 0 + var maximum: Double = 0 + } + private struct State { + var entries: [String: Entry] = [:] + var start = ContinuousClock.now + } + private static let state = Mutex(State()) + + static func record(_ name: String, seconds: Double) { + let report: String? = state.withLock { state in + var entry = state.entries[name] ?? Entry() + entry.count += 1 + entry.sum += seconds * 1000 + entry.maximum = max(entry.maximum, seconds * 1000) + state.entries[name] = entry + let now = ContinuousClock.now + let elapsed = state.start.duration(to: now) + guard elapsed >= .seconds(1) else { return nil } + let wall = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 + let total = state.entries.values.reduce(0) { $0 + $1.sum } + var lines = [String(format: "GPU_LEDGER busy=%.0f ms/s over %.1f s", total / wall, wall)] + for (label, value) in state.entries.sorted(by: { $0.value.sum > $1.value.sum }) { + let row = String(format: "GPU_LEDGER %7.2f ms/s avg %6.3f ms max %7.2f ms n=%5d ", + value.sum / wall, value.sum / Double(value.count), value.maximum, value.count) + lines.append(row + label) + } + state.entries.removeAll(keepingCapacity: true) + state.start = now + return lines.joined(separator: "\n") + "\n" + } + if let report { + try? FileHandle.standardOutput.write(contentsOf: Data(report.utf8)) + } + } + } + + /// Opt-in GPU execution timing for native WebGPU command buffers. + /// + /// `ADAENGINE_GPU_TIMINGS=1` requests the timestamp-query feature at device creation. Each command + /// buffer then writes a timestamp when it is created and another when it is committed, and + /// `addCompletedTimingHandler` receives the difference. Timestamps cost a query set and two small + /// buffers per command buffer, so they stay off unless profiling asks for them. + final class WGPUTimestampQuery: @unchecked Sendable { + static let isRequested = ProcessInfo.processInfo.environment["ADAENGINE_GPU_TIMINGS"] == "1" + + // Immutable Dawn handles; every use takes `webGPUDeviceLock`. + private let querySet: WebGPU.GPUQuerySet + private let resolveBuffer: WebGPU.GPUBuffer + private let readbackBuffer: WebGPU.GPUBuffer + private let instance: WebGPU.GPUInstance? + private static let byteCount = 2 * MemoryLayout.size + + /// Creates the query and writes the start timestamp, or returns nil when timing is off or unsupported. + /// Call under `webGPUDeviceLock`. + init?(device: WebGPU.GPUDevice, encoder: WebGPU.GPUCommandEncoder, instance: WebGPU.GPUInstance?) { + guard Self.isRequested, device.hasFeature(feature: .timestampQuery) else { + return nil + } + querySet = device.createQuerySet(descriptor: WebGPU.GPUQuerySetDescriptor(label: "GPU timing", type: .timestamp, count: 2)) + guard + let resolve = device.createBuffer(descriptor: WebGPU.GPUBufferDescriptor(usage: [.queryResolve, .copySrc], size: UInt64(Self.byteCount))), + let readback = device.createBuffer(descriptor: WebGPU.GPUBufferDescriptor(usage: [.mapRead, .copyDst], size: UInt64(Self.byteCount))) + else { + querySet.destroy() + return nil + } + resolveBuffer = resolve + readbackBuffer = readback + self.instance = instance + encoder.writeTimestamp(querySet: querySet, queryIndex: 0) + } + + /// Writes the end timestamp and copies both into the readback buffer. Call under `webGPUDeviceLock`. + func encodeEnd(on encoder: WebGPU.GPUCommandEncoder) { + encoder.writeTimestamp(querySet: querySet, queryIndex: 1) + encoder.resolveQuerySet(querySet: querySet, firstQuery: 0, queryCount: 2, destination: resolveBuffer, destinationOffset: 0) + encoder.copyBufferToBuffer( + source: resolveBuffer, + sourceOffset: 0, + destination: readbackBuffer, + destinationOffset: 0, + size: UInt64(Self.byteCount) + ) + } + + /// Reads the elapsed seconds after the command buffer finished, then releases the query resources. + func readElapsedSeconds() async -> Double? { + let result = Mutex(nil) + let readback = readbackBuffer + let info = WebGPU.GPUBufferMapCallbackInfo( + mode: .allowProcessEvents, + callback: { status, _ in + var seconds: Double? + if status == .success, let pointer = unsafe readback.getConstMappedRange(offset: 0, size: Self.byteCount) { + let start = unsafe pointer.load(as: UInt64.self) + let end = unsafe pointer.load(fromByteOffset: MemoryLayout.size, as: UInt64.self) + // Dawn reports nanoseconds; a reset or reordered pair yields no sample. + seconds = end > start ? Double(end - start) / 1_000_000_000 : nil + readback.unmap() + } + result.withLock { $0 = .some(seconds) } + } + ) + webGPUDeviceLock.withLock { _ in + info.withWGPUStruct { native in + native.callback = { status, message, userdata, _ in + guard let userdata else { return } + let callback = Unmanaged.fromOpaque(userdata).takeRetainedValue() as! WebGPU.GPUBufferMapCallback + callback(status, message.toString) + } + _ = readback.mapAsync(mode: .read, offset: 0, size: Self.byteCount, callbackInfo: native) + } + } + let deadline = ContinuousClock.now + .seconds(5) + var seconds: Double? + while ContinuousClock.now < deadline { + webGPUDeviceLock.withLock { _ in instance?.processEvents() } + if let value = result.withLock({ $0 }) { + seconds = value + break + } + await Task.yield() + } + webGPUDeviceLock.withLock { _ in + querySet.destroy() + resolveBuffer.destroy() + readbackBuffer.destroy() + } + return seconds + } + } +#endif diff --git a/Sources/AdaRender/Backends/WebGPU/PipelineDiskCache.swift b/Sources/AdaRender/Backends/WebGPU/PipelineDiskCache.swift new file mode 100644 index 000000000..02ad49cb0 --- /dev/null +++ b/Sources/AdaRender/Backends/WebGPU/PipelineDiskCache.swift @@ -0,0 +1,335 @@ +// +// PipelineDiskCache.swift +// AdaEngine +// + +// Dawn's blob cache exists only in native builds; WASI also lacks atomic file writes. +#if !WASM +import Foundation +import Synchronization + +/// Persistent key/value store backing Dawn's blob cache. +/// +/// Dawn hands the embedder opaque keys and blobs (translated and compiled shaders, and +/// pipeline state where the backend supports it) through `DawnCacheDeviceDescriptor`. +/// Storing them on disk lets later launches skip shader translation and driver +/// compilation, which otherwise costs hundreds of milliseconds per pipeline variant. +/// +/// Each entry is one file named by a 64-bit FNV-1a hash of the key. The file repeats +/// the full key, so a hash collision or a foreign file is a miss rather than wrong data. +/// Truncated or malformed files are deleted. The directory is trimmed to `byteBudget` +/// on creation, removing the least recently used entries first (a hit refreshes the +/// entry's modification date). +/// +/// Dawn may call the cache from several threads, so all access is serialized. +final class PipelineDiskCache: Sendable { + struct Statistics: Equatable { + var hits = 0 + var misses = 0 + var stores = 0 + var rejected = 0 + } + + static let defaultByteBudget = 512 * 1024 * 1024 + + let directory: URL + + private struct State: ~Copyable { + var statistics = Statistics() + /// The entry found by Dawn's size query, reused by the copy that follows it. + var lastLoaded: (key: Data, value: Data)? + } + + private static let magic: UInt32 = 0x4350_4441 // "ADPC" + private static let version: UInt32 = 1 + private static let headerSize = 4 + 4 + 8 + 8 + + private let state = Mutex(State()) + + /// Adapter folders untouched for this long are removed (a driver update changes the + /// adapter fingerprint, which would otherwise leave the old folder behind forever). + static let defaultStaleAdapterAge: TimeInterval = 30 * 24 * 60 * 60 + + /// Leftover files from writes interrupted by a crash are removed after this age. + private static let abandonedTemporaryFileAge: TimeInterval = 60 * 60 + + init( + directory: URL, + byteBudget: Int = PipelineDiskCache.defaultByteBudget, + staleAdapterAge: TimeInterval = PipelineDiskCache.defaultStaleAdapterAge, + now: Date = Date() + ) { + self.directory = directory + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + Self.prune(directory: directory, byteBudget: byteBudget, now: now) + Self.removeStaleSiblingAdapters(of: directory, olderThan: staleAdapterAge, now: now) + } + + /// Resolves the cache directory for one adapter. + /// + /// `ADAENGINE_PIPELINE_CACHE=0` disables the cache. `ADAENGINE_PIPELINE_CACHE_DIR` + /// replaces the default `/AdaEngine/PipelineCache`. Each adapter gets its own + /// subdirectory so switching GPUs or backends never evicts the other's entries. + static func directory( + environment: [String: String], + cachesDirectory: URL?, + adapterFingerprint: String + ) -> URL? { + if let flag = environment["ADAENGINE_PIPELINE_CACHE"]?.lowercased(), ["0", "false", "off", "no"].contains(flag) { + return nil + } + let root: URL + if let override = environment["ADAENGINE_PIPELINE_CACHE_DIR"], !override.isEmpty { + root = URL(fileURLWithPath: override, isDirectory: true) + } else if let cachesDirectory { + root = cachesDirectory + .appendingPathComponent("AdaEngine", isDirectory: true) + .appendingPathComponent("PipelineCache", isDirectory: true) + } else { + return nil + } + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in adapterFingerprint.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return root.appendingPathComponent(String(hash, radix: 16), isDirectory: true) + } + + var statistics: Statistics { + state.withLock { $0.statistics } + } + + func fileURL(for key: Data) -> URL { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in key { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + let name = String(hash, radix: 16) + "-" + String(key.count, radix: 16) + ".bin" + return directory.appendingPathComponent(name, isDirectory: false) + } + + // MARK: - Data API + + func load(key: Data) -> Data? { + state.withLock { state in + lookup(key: key, statistics: &state.statistics) + } + } + + func store(key: Data, value: Data) { + let entry = Self.encodeEntry(key: key, value: value) + state.withLock { state in + do { + try entry.write(to: fileURL(for: key), options: .atomic) + state.statistics.stores += 1 + if state.lastLoaded?.key == key { + state.lastLoaded = nil + } + } catch { + state.statistics.rejected += 1 + } + } + } + + // MARK: - Dawn callback API + + /// Implements `WGPUDawnLoadCacheDataFunction`. + /// + /// With no output buffer this returns the stored size (0 for a miss). With a buffer + /// it copies the blob and returns its size, or returns 0 if the sizes differ. + /// + /// The copy is served from the entry memoized by the size query, so a file replaced + /// by another process between the two calls cannot change the size Dawn was told. + /// Dawn serializes these calls per device, so the memo is always the preceding query. + func load(key: UnsafeRawBufferPointer, into output: UnsafeMutableRawBufferPointer?) -> Int { + let key = unsafe Data(key) + return state.withLock { state in + let value: Data? + if let lastLoaded = state.lastLoaded, lastLoaded.key == key { + value = lastLoaded.value + } else { + value = lookup(key: key, statistics: &state.statistics) + state.lastLoaded = value.map { (key, $0) } + } + guard let value else { + return 0 + } + guard let output = unsafe output else { + return value.count + } + state.lastLoaded = nil + guard output.count == value.count else { + return 0 + } + unsafe value.copyBytes(to: output) + return value.count + } + } + + /// Implements `WGPUDawnStoreCacheDataFunction`. + func store(key: UnsafeRawBufferPointer, value: UnsafeRawBufferPointer) { + unsafe store(key: Data(key), value: Data(value)) + } + + // MARK: - Entries + + static func encodeEntry(key: Data, value: Data) -> Data { + var data = Data(capacity: headerSize + key.count + value.count) + append(magic, to: &data) + append(version, to: &data) + append(UInt64(key.count), to: &data) + append(UInt64(value.count), to: &data) + data.append(key) + data.append(value) + return data + } + + private func lookup(key: Data, statistics: inout Statistics) -> Data? { + let url = fileURL(for: key) + guard let entry = try? Data(contentsOf: url) else { + statistics.misses += 1 + return nil + } + switch Self.decodeEntry(entry, expectedKey: key) { + case let .value(value): + statistics.hits += 1 + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path) + return value + case .otherKey: + statistics.misses += 1 + return nil + case .malformed: + statistics.rejected += 1 + statistics.misses += 1 + try? FileManager.default.removeItem(at: url) + return nil + } + } + + private enum Decoded { + case value(Data) + case otherKey + case malformed + } + + private static func decodeEntry(_ entry: Data, expectedKey: Data) -> Decoded { + guard entry.count >= headerSize, + read(UInt32.self, from: entry, at: 0) == magic, + read(UInt32.self, from: entry, at: 4) == version + else { + return .malformed + } + let keyCount = read(UInt64.self, from: entry, at: 8) + let valueCount = read(UInt64.self, from: entry, at: 16) + let available = UInt64(entry.count - headerSize) + guard keyCount <= available, valueCount == available - keyCount else { + return .malformed + } + let keyStart = entry.startIndex + headerSize + let valueStart = keyStart + Int(keyCount) + guard entry[keyStart..(_ value: T, to data: inout Data) { + unsafe withUnsafeBytes(of: value.littleEndian) { unsafe data.append(contentsOf: $0) } + } + + private static func read(_ type: T.Type, from data: Data, at offset: Int) -> T { + var value: T = 0 + let start = data.startIndex + offset + for index in 0...size { + value |= T(data[start + index]) << (index * 8) + } + return value + } + + // MARK: - Pruning + + private static func prune(directory: URL, byteBudget: Int, now: Date) { + let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey] + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles] + ) else { + return + } + var entries: [(url: URL, size: Int, date: Date)] = [] + var total = 0 + for url in files { + guard let values = try? url.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { + continue + } + let date = values.contentModificationDate ?? .distantPast + guard url.pathExtension == "bin" else { + // Temporary files of atomic writes interrupted by a crash. + if now.timeIntervalSince(date) > abandonedTemporaryFileAge { + try? FileManager.default.removeItem(at: url) + } + continue + } + let size = values.fileSize ?? 0 + entries.append((url, size, date)) + total += size + } + guard total > byteBudget else { + return + } + entries.sort { $0.date < $1.date } + for entry in entries { + guard total > byteBudget else { + break + } + if (try? FileManager.default.removeItem(at: entry.url)) != nil { + total -= entry.size + } + } + } + + /// Removes other adapter folders under the same root that have not been used for + /// `age`. Only folders that look like ours (hex names holding only cache entries) + /// are touched, so a user-chosen `ADAENGINE_PIPELINE_CACHE_DIR` root stays safe. + private static func removeStaleSiblingAdapters(of directory: URL, olderThan age: TimeInterval, now: Date) { + let manager = FileManager.default + let root = directory.deletingLastPathComponent() + guard let siblings = try? manager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return + } + for sibling in siblings where sibling.lastPathComponent != directory.lastPathComponent { + let name = sibling.lastPathComponent + guard (1...16).contains(name.count), name.allSatisfy(\.isHexDigit), + (try? sibling.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, + let files = try? manager.contentsOfDirectory( + at: sibling, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [] + ) + else { + continue + } + var newest = Date.distantPast + var ownedOnly = true + for file in files { + let values = try? file.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true, file.pathExtension == "bin" else { + ownedOnly = false + break + } + newest = max(newest, values?.contentModificationDate ?? .distantPast) + } + if ownedOnly, now.timeIntervalSince(newest) > age { + try? manager.removeItem(at: sibling) + } + } + } +} +#endif diff --git a/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift b/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift index 5ec354de1..5b2365345 100644 --- a/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift +++ b/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift @@ -114,6 +114,22 @@ return adapter } + /// Release builds skip Dawn's per-call WebGPU validation, which costs about 1 ms of CPU per frame in large + /// scenes; the engine's own usage is validated by debug builds and tests. `ADAENGINE_WEBGPU_VALIDATION=1` + /// keeps validation in a release build, `=0` skips it in a debug build. + private static var skipsValidation: Bool { + switch ProcessInfo.processInfo.environment["ADAENGINE_WEBGPU_VALIDATION"] { + case "1": return false + case "0": return true + default: + #if DEBUG + return false + #else + return true + #endif + } + } + private static var adapterOptions: WebGPU.GPURequestAdapterOptions { #if os(Windows) && canImport(CWebGPUDLSS) WebGPU.GPURequestAdapterOptions( @@ -173,14 +189,65 @@ maxColorAttachmentBytesPerSample: min(48, supportedLimits.maxColorAttachmentBytesPerSample) ) var features: [WebGPU.GPUFeatureName] = [.depth32FloatStencil8, .float32Filterable] + #if !WASM + // GPU-driven shadow batching issues one multi-draw per draw group when both are available. + features += [.multiDrawIndirect, .indirectFirstInstance].filter { adapter.hasFeature(feature: $0) } + // Profiling only: per-command-buffer GPU timings (WGPUTimestampQuery). + if WGPUTimestampQuery.isRequested, adapter.hasFeature(feature: .timestampQuery) { + features.append(.timestampQuery) + } + #endif var nativeToggles: (any WebGPU.GPUChainedStruct)? + var enabledToggles: [String] = [] #if os(Windows) && canImport(CWebGPUDLSS) // Native-only Dawn extensions. This is never requested by browser WebGPU. features += [.sharedTextureMemoryD3D12Resource, .sharedFenceDXGISharedHandle].filter { adapter.hasFeature(feature: $0) } - nativeToggles = WebGPU.DawnTogglesDescriptor(enabledToggles: ["allow_unsafe_apis"]) + enabledToggles.append("allow_unsafe_apis") + #endif + if Self.skipsValidation { + enabledToggles.append("skip_validation") + } + var disabledToggles: [String] = [] + #if !WASM + if WGPUTimestampQuery.isRequested { + // Profiling needs microsecond timestamps, not the privacy-quantized ones. + disabledToggles.append("timestamp_quantization") + } #endif + if !enabledToggles.isEmpty || !disabledToggles.isEmpty { + nativeToggles = WebGPU.DawnTogglesDescriptor(enabledToggles: enabledToggles, disabledToggles: disabledToggles) + } + let pipelineCache = makePipelineCache(adapter: adapter, logger: logger) + // Dawn may call the cache until the device is destroyed, which can outlive + // this backend, so the device chain owns one retain for the process lifetime. + let pipelineCacheUserdata = unsafe pipelineCache.map { unsafe Unmanaged.passRetained($0).toOpaque() } + if let pipelineCacheUserdata = unsafe pipelineCacheUserdata { + nativeToggles = unsafe WebGPU.DawnCacheDeviceDescriptor( + isolationKey: "AdaEngine", + loadDataFunction: { key, keySize, value, valueSize, userdata in + guard let key = unsafe key, let userdata = unsafe userdata else { + return 0 + } + let cache = unsafe Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let output = unsafe value.map { unsafe UnsafeMutableRawBufferPointer(start: $0, count: valueSize) } + return unsafe cache.load(key: UnsafeRawBufferPointer(start: key, count: keySize), into: output) + }, + storeDataFunction: { key, keySize, value, valueSize, userdata in + guard let key = unsafe key, let value = unsafe value, let userdata = unsafe userdata else { + return + } + let cache = unsafe Unmanaged.fromOpaque(userdata).takeUnretainedValue() + unsafe cache.store( + key: UnsafeRawBufferPointer(start: key, count: keySize), + value: UnsafeRawBufferPointer(start: value, count: valueSize) + ) + }, + functionUserdata: pipelineCacheUserdata, + nextInChain: nativeToggles + ) + } let descriptor = WebGPU.GPUDeviceDescriptor( label: "AdaEngine WebGPU Device", requiredFeatures: features, @@ -232,11 +299,52 @@ } guard requestStatus == .success, let device = requestedDevice else { + if let pipelineCacheUserdata = unsafe pipelineCacheUserdata { + unsafe Unmanaged.fromOpaque(pipelineCacheUserdata).release() + } throw WebGPUBackendError.requestDeviceFailed(requestMessage ?? "unknown error") } return device } + + /// User cache root. On Windows Foundation's caches directory is %TEMP%, which + /// Disk Cleanup empties, so the per-user %LOCALAPPDATA% is used instead. + private static func pipelineCacheRoot() -> URL? { + #if os(Windows) + if let localAppData = ProcessInfo.processInfo.environment["LOCALAPPDATA"], !localAppData.isEmpty { + return URL(fileURLWithPath: localAppData, isDirectory: true) + } + #endif + return try? FileSystem.current.url(for: .cachesDirectory) + } + + /// Persistent Dawn blob cache, so translated and compiled shaders survive restarts. + private static func makePipelineCache(adapter: WebGPU.GPUAdapter, logger: Logger) -> PipelineDiskCache? { + var info = WebGPU.GPUAdapterInfo() + guard adapter.getInfo(info: &info) == .success else { + return nil + } + let fingerprint = [ + "\(info.backendType.rawValue)", + String(info.vendorID, radix: 16), + String(info.deviceID, radix: 16), + info.vendor, + info.architecture, + info.device, + info.description + ].joined(separator: "|") + guard let directory = PipelineDiskCache.directory( + environment: ProcessInfo.processInfo.environment, + cachesDirectory: pipelineCacheRoot(), + adapterFingerprint: fingerprint + ) else { + logger.info("WebGPU pipeline disk cache disabled.") + return nil + } + logger.info("WebGPU pipeline disk cache: \(directory.path)") + return PipelineDiskCache(directory: directory) + } #endif } diff --git a/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift b/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift index 3e936d0cc..0ded92950 100644 --- a/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift +++ b/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift @@ -19,6 +19,7 @@ public let context: WGPUContext public let supportsHybridReflections: Bool public let supportsLightingSurfaceBuffer: Bool + public let supportsMultiDrawIndirect: Bool #if !WASM private let meshletGeometryCache = WGPUMeshletGeometryCache() #endif @@ -47,10 +48,15 @@ // Until hybrid WGSL variants ship, retain the four-target IBL path rather than an undefined MRT. supportsHybridReflections = false supportsLightingSurfaceBuffer = false + supportsMultiDrawIndirect = false // Standard WebGPU has no multi-draw. #else var limits = WebGPU.GPULimits() supportsHybridReflections = context.device.getLimits(limits: &limits) == .success && limits.maxColorAttachmentBytesPerSample >= 40 supportsLightingSurfaceBuffer = limits.maxColorAttachmentBytesPerSample >= 48 && limits.maxColorAttachments >= 6 + // Multi-draw arguments select each draw's instances with firstInstance, which needs both features. + supportsMultiDrawIndirect = ProcessInfo.processInfo.environment["ADAENGINE_SHADOW_MULTIDRAW"] != "0" + && context.device.hasFeature(feature: .multiDrawIndirect) + && context.device.hasFeature(feature: .indirectFirstInstance) #endif } @@ -58,6 +64,30 @@ WGPUVisibilityProcessor(context: context) } + public var supportsGPUShadowCull: Bool { true } + + public func createGPUShadowCullProcessor() -> (any GPUShadowCullProcessor)? { + WGPUShadowCullProcessor(context: context) + } + + /// Native only: the draws read GPU-written counts and select instances through `firstInstance`. + /// `ADAENGINE_SHADOW_MESHLETS=0` keeps whole-part shadow culling. + public var supportsGPUShadowClusterCull: Bool { + #if WASM + false + #else + supportsMultiDrawIndirect && ProcessInfo.processInfo.environment["ADAENGINE_SHADOW_MESHLETS"] != "0" + #endif + } + + public func createGPUShadowClusterCullProcessor() -> (any GPUShadowClusterCullProcessor)? { + #if WASM + nil + #else + supportsGPUShadowClusterCull ? WGPUShadowClusterCullProcessor(context: context) : nil + #endif + } + public func createMeshletVisibilityProcessor() -> (any MeshletVisibilityProcessor)? { #if WASM nil // Browser acceptance is separate from native Dawn validation. @@ -103,7 +133,8 @@ context.device.createBuffer( descriptor: WebGPU.GPUBufferDescriptor( label: label, - usage: [.index, .copyDst], + // Copy source for the pooled shadow geometry. + usage: [.index, .copyDst, .copySrc], size: UInt64(length) ) ) diff --git a/Sources/AdaRender/Camera/CameraPlugin.swift b/Sources/AdaRender/Camera/CameraPlugin.swift index 9d3a83d95..3f328b521 100644 --- a/Sources/AdaRender/Camera/CameraPlugin.swift +++ b/Sources/AdaRender/Camera/CameraPlugin.swift @@ -84,6 +84,11 @@ public struct RenderViewTarget: @unchecked Sendable { public var temporalReset = true public var temporalUpscalingActive = false public var irradianceGridActive = false + /// The main pass writes the path tracer's first-hit surface: base colour into ``diffuseSurface3DTexture`` and + /// emission into ``reflectionResponse3DTexture`` (see `Documentation/Designs/path-tracing-denoising.md`). + public var pathTracingSurfaceActive = false + /// This view's temporal upscaler can denoise a path-traced frame (DLSS Ray Reconstruction). + public var rayReconstructionAvailable = false /// Resolved pixel-perfect settings for this frame. Nil when the camera is not pixel perfect or a feature that /// takes precedence (temporal upscaling, foveation) is active. @@ -141,6 +146,7 @@ func ConfigurateRenderViewTarget( renderViewTarget.mainTexture = renderViewTarget.temporalInputTexture } renderViewTarget.temporalUpscalingActive = false + renderViewTarget.rayReconstructionAvailable = false renderViewTarget.temporalJitter = .zero renderViewTarget.pixelPerfect = nil // A drawable belongs to this frame only, including frames we cannot render. @@ -369,6 +375,10 @@ struct CameraRenderNode: RenderNode { } } +extension RenderNodeLabel { + package static var cameraRender: Self { CameraRenderNode.name } +} + @Component public struct CameraRenderGraph { public let subgraphLabel: RenderGraph.Label diff --git a/Sources/AdaRender/Extract.swift b/Sources/AdaRender/Extract.swift index 42cea4f0e..f1f3598a1 100644 --- a/Sources/AdaRender/Extract.swift +++ b/Sources/AdaRender/Extract.swift @@ -5,6 +5,7 @@ // Created by Vladislav Prusakov on 25.11.2025. // +import AdaApp import AdaECS /// A property wrapper that allows you to extract a resource from the main world. @@ -38,6 +39,10 @@ extension Extract: SystemParameter { _value = T(from: mainWorld) } _value?.update(from: mainWorld) + } else { + // A pipelined render world drops `MainWorld` after `.extract`: the main world is already simulating + // the next frame, and queries read its storage live. Copy the data during extraction instead. + assert(!RenderPipelining.isActive(in: world), "Extract<\(T.self)> is only valid in the .extract schedule of a pipelined render world") } } } diff --git a/Sources/AdaRender/Materials/PBRMaterial.swift b/Sources/AdaRender/Materials/PBRMaterial.swift index 51bc5a0d1..2112a3095 100644 --- a/Sources/AdaRender/Materials/PBRMaterial.swift +++ b/Sources/AdaRender/Materials/PBRMaterial.swift @@ -96,8 +96,8 @@ public enum PBRLayerTexture: Int, CaseIterable, Hashable, Sendable { /// A material that uses Physically Based Rendering (PBR) to define its appearance. public class PBRMaterial: Material, @unchecked Sendable { /// Optional fragment graph; lighting, skinning and render-pass configuration stay shared. - @_spi(Internal) public var terrainBindings: TerrainMaterialBindings? { didSet { uniformRevision &+= 1 } } - public var graphMaterial: ShaderGraphMaterial? { didSet { uniformRevision &+= 1 } } + @_spi(Internal) public var terrainBindings: TerrainMaterialBindings? { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var graphMaterial: ShaderGraphMaterial? { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } public var effectiveAlphaMode: AlphaMode { switch graphMaterial?.program.renderSettings.mode { case .opaque: .opaque @@ -108,67 +108,67 @@ public class PBRMaterial: Material, @unchecked Sendable { } public var effectiveAlphaCutoff: Float { graphMaterial?.program.renderSettings.alphaCutoff ?? alphaCutoff } public var effectiveDoubleSided: Bool { graphMaterial?.program.renderSettings.doubleSided ?? doubleSided } - public var baseColorFactor: Vector4 = .one { didSet { uniformRevision &+= 1 } } - public var baseColorTexture: Texture2D? - public var metallicFactor: Float = 1 - public var roughnessFactor: Float = 1 - public var metallicRoughnessTexture: Texture2D? - public var normalTexture: Texture2D? - public var emissiveTexture: Texture2D? - public var emissiveStrength: Float = 0 - public var emissiveFactor: Vector3 = .one { didSet { uniformRevision &+= 1 } } - public var occlusionTexture: Texture2D? { didSet { uniformRevision &+= 1 } } - public var occlusionStrength: Float = 1 { didSet { uniformRevision &+= 1 } } + public var baseColorFactor: Vector4 = .one { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var baseColorTexture: Texture2D? { didSet { stateRevision &+= 1 } } + public var metallicFactor: Float = 1 { didSet { stateRevision &+= 1 } } + public var roughnessFactor: Float = 1 { didSet { stateRevision &+= 1 } } + public var metallicRoughnessTexture: Texture2D? { didSet { stateRevision &+= 1 } } + public var normalTexture: Texture2D? { didSet { stateRevision &+= 1 } } + public var emissiveTexture: Texture2D? { didSet { stateRevision &+= 1 } } + public var emissiveStrength: Float = 0 { didSet { stateRevision &+= 1 } } + public var emissiveFactor: Vector3 = .one { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var occlusionTexture: Texture2D? { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var occlusionStrength: Float = 1 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Linear incident diffuse lighting / pi on UV1. Static lighting replaces diffuse IBL, preserving its specular. - public var lightmapTexture: Texture2D? { didSet { uniformRevision &+= 1 } } - public var lightmapScaleOffset: Vector4 = [1, 1, 0, 0] { didSet { uniformRevision &+= 1 } } - public var lightmapIntensity: Float = 1 { didSet { uniformRevision &+= 1 } } - public var normalScale: Float = 1 { didSet { uniformRevision &+= 1 } } + public var lightmapTexture: Texture2D? { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var lightmapScaleOffset: Vector4 = [1, 1, 0, 0] { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var lightmapIntensity: Float = 1 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var normalScale: Float = 1 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Height map for parallax occlusion mapping, read from the red channel (`1` is the top of the surface, `0` the /// deepest point). Use a linear (non-sRGB) texture. Parallax shifts every material texture that shares /// ``heightTextureCoordinate``, so the relief stays consistent with color, normal and occlusion maps. /// The shader has no free sampler slot, so the height map is read with the base color texture's sampler: /// give both the same address and filter modes. - public var heightTexture: Texture2D? { didSet { uniformRevision &+= 1 } } + public var heightTexture: Texture2D? { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Depth of the relief in world units, measured from the top of the surface. `0` disables parallax. - public var heightScale: Float = 0.05 { didSet { uniformRevision &+= 1 } } + public var heightScale: Float = 0.05 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// UV set the height map is sampled with. - public var heightTextureCoordinate: Int = 0 { didSet { uniformRevision &+= 1 } } + public var heightTextureCoordinate: Int = 0 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Largest number of ray-march steps, used at grazing angles (a quarter of it is used head-on). Clamped to `4...64`. - public var heightMaxLayers: Int = 32 { didSet { uniformRevision &+= 1 } } + public var heightMaxLayers: Int = 32 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Camera distance in view units beyond which parallax has faded out completely. It fades over the last half. - public var heightFadeDistance: Float = 30 { didSet { uniformRevision &+= 1 } } + public var heightFadeDistance: Float = 30 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } public enum AlphaMode: UInt8, Sendable { case opaque, mask, blend } - public var alphaMode: AlphaMode = .opaque { didSet { uniformRevision &+= 1 } } - public var alphaCutoff: Float = 0.5 { didSet { uniformRevision &+= 1 } } - public var doubleSided = false { didSet { uniformRevision &+= 1 } } + public var alphaMode: AlphaMode = .opaque { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var alphaCutoff: Float = 0.5 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } + public var doubleSided = false { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// UV sets for base color, metallic/roughness, normal, occlusion, and emission. - public var textureCoordinates: [Int] = [0, 0, 0, 0, 0] { didSet { uniformRevision &+= 1 } } + public var textureCoordinates: [Int] = [0, 0, 0, 0, 0] { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// A negative value emits everywhere. Non-negative values fade emission /// out as direct illumination rises above this threshold. - public var emissiveLightThreshold: Float = -1 + public var emissiveLightThreshold: Float = -1 { didSet { stateRevision &+= 1 } } /// UV transforms for base color, metallic/roughness, normal, occlusion, and emission. - public var textureTransforms: [PBRTextureTransform] = Array(repeating: .identity, count: 5) { didSet { uniformRevision &+= 1 } } + public var textureTransforms: [PBRTextureTransform] = Array(repeating: .identity, count: 5) { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Index of refraction of the dielectric layer. The default 1.5 gives the usual 4% reflectance at normal incidence. - public var ior: Float = 1.5 { didSet { uniformRevision &+= 1 } } + public var ior: Float = 1.5 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Strength of the dielectric specular reflection (`KHR_materials_specular`). - public var specularFactor: Float = 1 { didSet { uniformRevision &+= 1 } } + public var specularFactor: Float = 1 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Tint of the dielectric specular reflection (`KHR_materials_specular`). - public var specularColorFactor: Vector3 = .one { didSet { uniformRevision &+= 1 } } + public var specularColorFactor: Vector3 = .one { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Strength of a clear lacquer layer over the surface (`KHR_materials_clearcoat`); 0 turns the layer off. - public var clearcoatFactor: Float = 0 { didSet { uniformRevision &+= 1 } } + public var clearcoatFactor: Float = 0 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Roughness of the clearcoat layer, independent of the base layer's roughness. - public var clearcoatRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1 } } + public var clearcoatRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Color of a soft fabric sheen over the surface (`KHR_materials_sheen`); black turns the layer off. - public var sheenColorFactor: Vector3 = .zero { didSet { uniformRevision &+= 1 } } + public var sheenColorFactor: Vector3 = .zero { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Roughness of the sheen layer. - public var sheenRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1 } } + public var sheenRoughnessFactor: Float = 0 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// How much highlights stretch (`KHR_materials_anisotropy`, 0 to 1); 0 keeps them round. - public var anisotropyStrength: Float = 0 { didSet { uniformRevision &+= 1 } } + public var anisotropyStrength: Float = 0 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Rotation of the stretch direction from the surface tangent, in radians. - public var anisotropyRotation: Float = 0 { didSet { uniformRevision &+= 1 } } + public var anisotropyRotation: Float = 0 { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// Textures of the clearcoat, sheen, anisotropy and specular layers. Each multiplies its layer's factor. - public var layerTextures: [PBRLayerTexture: PBRTextureBinding] = [:] { didSet { uniformRevision &+= 1 } } + public var layerTextures: [PBRLayerTexture: PBRTextureBinding] = [:] { didSet { uniformRevision &+= 1; stateRevision &+= 1 } } /// The optional shading layers and layer textures a material needs. The main shader is compiled with only what a /// material uses; the empty set is the plain shader, which is also the only one that has a shader cache and a web build. @@ -191,6 +191,9 @@ public class PBRMaterial: Material, @unchecked Sendable { public static let terrain = Self(rawValue: 1 << 16) /// Diagnostic line pass; never an authored material feature. public static let wireframe = Self(rawValue: 1 << 17) + /// Path-traced views: the ``hybridReflection`` and ``diffuseSurface`` outputs carry emission and base colour + /// for the tracer's first hit instead of their raster meanings. Always combined with both of them. + public static let pathTracingSurface = Self(rawValue: 1 << 18) /// Every layer, without textures. public static let all: ShaderFeatures = [.clearcoat, .sheen, .anisotropy] @@ -208,6 +211,7 @@ public class PBRMaterial: Material, @unchecked Sendable { if contains(.irradianceGrid) { macros.append("ADA_IRRADIANCE_GRID") } if contains(.terrain) { macros.append("ADA_TERRAIN") } if contains(.wireframe) { macros.append("ADA_WIREFRAME") } + if contains(.pathTracingSurface) { macros.append("ADA_PATH_TRACING_SURFACE") } for texture in PBRLayerTexture.allCases where contains(texture.feature) { macros.append(texture.macro) } @@ -234,6 +238,71 @@ public class PBRMaterial: Material, @unchecked Sendable { /// Changes whenever parameters in the shared PBR uniform snapshot are edited. @_spi(Internal) public private(set) var uniformRevision: UInt64 = 0 + /// Changes whenever any stored property changes. + @_spi(Internal) public private(set) var stateRevision: UInt64 = 0 + + /// For a render snapshot, the live material it copies. + @_spi(Internal) public private(set) weak var snapshotSource: PBRMaterial? + + /// The last snapshot handed to a pipelined render world, with the revision it copied. + private var cachedRenderSnapshot: (revision: UInt64, material: PBRMaterial)? + + /// An immutable copy for a pipelined render world, taken at extraction. + /// + /// A pipelined render world draws frame N while gameplay edits materials for frame N+1. It draws this + /// copy instead of the live object, so edits reach the renderer at the next extraction and never race + /// with it. The copy is reused until the material changes. Call only at extraction, while no pipelined + /// render is in flight. Subclasses are returned unchanged; they are not snapshotted. + @_spi(Internal) public func renderSnapshot() -> PBRMaterial { + guard type(of: self) == PBRMaterial.self else { return self } + if let cachedRenderSnapshot, cachedRenderSnapshot.revision == stateRevision { + return cachedRenderSnapshot.material + } + let copy = PBRMaterial() + copy.snapshotSource = self + copy.assetMetaInfo = assetMetaInfo + copy.terrainBindings = terrainBindings + copy.graphMaterial = graphMaterial + copy.baseColorFactor = baseColorFactor + copy.baseColorTexture = baseColorTexture + copy.metallicFactor = metallicFactor + copy.roughnessFactor = roughnessFactor + copy.metallicRoughnessTexture = metallicRoughnessTexture + copy.normalTexture = normalTexture + copy.emissiveTexture = emissiveTexture + copy.emissiveStrength = emissiveStrength + copy.emissiveFactor = emissiveFactor + copy.occlusionTexture = occlusionTexture + copy.occlusionStrength = occlusionStrength + copy.lightmapTexture = lightmapTexture + copy.lightmapScaleOffset = lightmapScaleOffset + copy.lightmapIntensity = lightmapIntensity + copy.normalScale = normalScale + copy.heightTexture = heightTexture + copy.heightScale = heightScale + copy.heightTextureCoordinate = heightTextureCoordinate + copy.heightMaxLayers = heightMaxLayers + copy.heightFadeDistance = heightFadeDistance + copy.alphaMode = alphaMode + copy.alphaCutoff = alphaCutoff + copy.doubleSided = doubleSided + copy.textureCoordinates = textureCoordinates + copy.emissiveLightThreshold = emissiveLightThreshold + copy.textureTransforms = textureTransforms + copy.ior = ior + copy.specularFactor = specularFactor + copy.specularColorFactor = specularColorFactor + copy.clearcoatFactor = clearcoatFactor + copy.clearcoatRoughnessFactor = clearcoatRoughnessFactor + copy.sheenColorFactor = sheenColorFactor + copy.sheenRoughnessFactor = sheenRoughnessFactor + copy.anisotropyStrength = anisotropyStrength + copy.anisotropyRotation = anisotropyRotation + copy.layerTextures = layerTextures + cachedRenderSnapshot = (stateRevision, copy) + return copy + } + public init() { // FIXME: (Vlad) We need a way to specify the shader for PBR material. // For now we use a dummy shader source. diff --git a/Sources/AdaRender/Misc/GPUShadowCull.swift b/Sources/AdaRender/Misc/GPUShadowCull.swift new file mode 100644 index 000000000..7a296a84b --- /dev/null +++ b/Sources/AdaRender/Misc/GPUShadowCull.swift @@ -0,0 +1,249 @@ +import Math + +/// One shadow-casting instance of one pooled mesh part, culled on the GPU against every shadow view (48 bytes). +public struct GPUShadowCaster: Sendable, Equatable { + /// World-space bounds minimum. `w` is 1 when the caster must always be drawn (no bounds, forced visible). + public var minimum: Vector4 + /// World-space bounds maximum. + public var maximum: Vector4 + /// Index of the caster's part in ``GPUShadowCullInput/parts``. + public var part: UInt32 + /// Record index of the caster in ``GPUShadowCullInput/instances``. + public var instance: UInt32 + private var padding0: UInt32 = 0 + private var padding1: UInt32 = 0 + + public init(bounds: AABB?, forceVisible: Bool, part: UInt32, instance: UInt32) { + let alwaysVisible = forceVisible || bounds == nil + minimum = Vector4(bounds?.min ?? .zero, alwaysVisible ? 1 : 0) + maximum = Vector4(bounds?.max ?? .zero, 0) + self.part = part + self.instance = instance + } +} + +/// The six clip planes of one shadow view, as rows of its view-projection (`row3 ± row0`, `row3 ± row1`, `row2`, +/// `row3 - row2`), matching the CPU mesh-visibility frustum test (112 bytes). +public struct GPUShadowCullView: Sendable { + public var plane0: Vector4 + public var plane1: Vector4 + public var plane2: Vector4 + public var plane3: Vector4 + public var plane4: Vector4 + public var plane5: Vector4 + /// 0 draws every caster in this view (frustum culling disabled). + public var culls: UInt32 + private var padding0: UInt32 = 0 + private var padding1: UInt32 = 0 + private var padding2: UInt32 = 0 + + public init(viewProjection: Transform3D, culls: Bool) { + let row0 = viewProjection.row(at: 0) + let row1 = viewProjection.row(at: 1) + let row2 = viewProjection.row(at: 2) + let row3 = viewProjection.row(at: 3) + plane0 = row3 + row0 + plane1 = row3 - row0 + plane2 = row3 + row1 + plane3 = row3 - row1 + plane4 = row2 + plane5 = row3 - row2 + self.culls = culls ? 1 : 0 + } +} + +/// Casters, instance records and pooled parts shared by every shadow view of a frame. +/// +/// For view `v` and part `p`, indirect draw `v * parts.count + p` draws the instances that survived culling. +/// They are written to output records `v * casters.count + segments[p]` and onward. +public struct GPUShadowCullInput: Sendable { + public var casters: [GPUShadowCaster] = [] + /// Packed instance records, `instanceStride` bytes each. + public var instances: [UInt8] = [] + public var instanceStride = 0 + /// Leading bytes of each record that the shadow passes read; the remainder is not copied. + public var copiedBytes = 0 + /// Index count, first index and base vertex of each pooled part. Instance counts are ignored. + public var parts: [IndexedIndirectArguments] = [] + /// First output record of each part inside a view's range; the parts' caster counts partition `casters.count`. + public var segments: [UInt32] = [] + /// Changes whenever any array above changes, so unchanged inputs are not uploaded again. + public var revision: UInt64 = 0 + + public init() {} + + /// Whether the arrays describe a consistent, non-empty input. + public var isValid: Bool { + guard !casters.isEmpty, !parts.isEmpty, segments.count == parts.count, + instanceStride > 0, instanceStride % 4 == 0, copiedBytes > 0, copiedBytes % 4 == 0, copiedBytes <= instanceStride, + instances.count % instanceStride == 0 + else { return false } + let records = instances.count / instanceStride + var counts = [Int](repeating: 0, count: parts.count) + for caster in casters { + guard Int(caster.part) < parts.count, Int(caster.instance) < records else { return false } + counts[Int(caster.part)] += 1 + } + var next = 0 + for part in parts.indices { + guard Int(segments[part]) == next else { return false } + next += counts[part] + } + return next == casters.count + } +} + +/// Compacted instances and indirect arguments for every culled view. +public struct GPUShadowCullOutput: Sendable { + public let instances: any Buffer + public let arguments: any Buffer + public let viewCount: Int + public let partCount: Int + public let casterCount: Int + public let instanceStride: Int + + public init(instances: any Buffer, arguments: any Buffer, viewCount: Int, partCount: Int, casterCount: Int, instanceStride: Int) { + self.instances = instances + self.arguments = arguments + self.viewCount = viewCount + self.partCount = partCount + self.casterCount = casterCount + self.instanceStride = instanceStride + } + + /// Byte offset of the indirect arguments for one view and part. + public func argumentOffset(view: Int, part: Int) -> Int { + (view * partCount + part) * MemoryLayout.stride + } + + /// First output instance record of one view and part. + public func firstInstance(view: Int, segment: UInt32) -> Int { + view * casterCount + Int(segment) + } +} + +/// Frustum-culls shadow casters for many shadow views in one GPU pass and writes indexed indirect draws. +/// +/// The test never rejects a caster that the CPU frustum test keeps: it uses the same planes with a small +/// tolerance toward drawing, and an extra draw of an off-view caster is clipped without changing any pixel. +/// One owner (render node and camera) uses an instance; its frame ring is not shared. +public protocol GPUShadowCullProcessor: AnyObject, Sendable { + /// Encodes culling before the shadow render pass on `command`. With `firstInstance`, each indirect draw + /// selects its compacted instances through `firstInstance`; otherwise it is 0 and the caller binds the + /// instance buffer at ``GPUShadowCullOutput/firstInstance(view:segment:)`` records. + func encode(on command: any CommandBuffer, input: GPUShadowCullInput, views: [GPUShadowCullView], firstInstance: Bool) -> GPUShadowCullOutput? +} + +// MARK: - Meshlet clusters + +/// One meshlet cluster of a cluster caster: a large static part culled per cluster instead of as a whole (48 bytes). +/// +/// See `Documentation/Designs/meshlet-shadow-culling.md`. +public struct GPUShadowCluster: Sendable, Equatable { + /// World-space bounds minimum (`w` unused). + public var minimum: Vector4 + /// World-space bounds maximum (`w` unused). + public var maximum: Vector4 + /// First index of the cluster in its part's own index buffer. + public var firstIndex: UInt32 + /// Index count of the cluster. + public var indexCount: UInt32 + /// Index of the cluster's part in ``GPUShadowClusterInput/segments``. + public var part: UInt32 + /// Record index of the part's caster in ``GPUShadowClusterInput/instances``. + public var instance: UInt32 + + public init(bounds: AABB, firstIndex: UInt32, indexCount: UInt32, part: UInt32, instance: UInt32) { + minimum = Vector4(bounds.min, 0) + maximum = Vector4(bounds.max, 0) + self.firstIndex = firstIndex + self.indexCount = indexCount + self.part = part + self.instance = instance + } +} + +/// Clusters of every cluster caster, shared by every shadow view of a frame. +/// +/// For view `v`, the surviving clusters of part `p` are written as consecutive indirect draws starting at +/// argument `v * clusters.count + segments[p]`; their number is the `UInt32` counter `v * segments.count + p`. +public struct GPUShadowClusterInput: Sendable, Equatable { + public var clusters: [GPUShadowCluster] = [] + /// Packed instance records, `instanceStride` bytes each, one per part; bound as per-instance vertex data. + public var instances: [UInt8] = [] + public var instanceStride = 0 + /// First cluster of each part. Parts' clusters are consecutive, so a part's cluster count is the next segment's + /// start (or `clusters.count`) minus its own. + public var segments: [UInt32] = [] + /// Changes whenever any array above changes, so unchanged inputs are not uploaded again. + public var revision: UInt64 = 0 + + public init() {} + + /// Number of clusters of part `part`: the most draws it can issue in one view. + public func clusterCount(part: Int) -> Int { + let end = part + 1 < segments.count ? Int(segments[part + 1]) : clusters.count + return end - Int(segments[part]) + } + + /// Whether the arrays describe a consistent, non-empty input. + public var isValid: Bool { + guard !clusters.isEmpty, !segments.isEmpty, segments[0] == 0, instanceStride > 0, instanceStride % 4 == 0, + instances.count % instanceStride == 0 + else { return false } + let records = instances.count / instanceStride + for part in segments.indices { + let end = part + 1 < segments.count ? Int(segments[part + 1]) : clusters.count + guard end > Int(segments[part]) else { return false } + for cluster in clusters[Int(segments[part])..= records { + return false + } + } + return true + } +} + +/// Indirect draws of the clusters that survived culling, and their GPU-written counts, for every culled view. +public struct GPUShadowClusterCullOutput: Sendable { + /// Indexed indirect arguments; see ``argumentOffset(view:part:)``. + public let arguments: any Buffer + /// One `UInt32` draw count per (view, part); see ``countOffset(view:part:)``. + public let counts: any Buffer + /// The input's instance records, readable as per-instance vertex data. + public let instances: any Buffer + public let viewCount: Int + public let input: GPUShadowClusterInput + + public init(arguments: any Buffer, counts: any Buffer, instances: any Buffer, viewCount: Int, input: GPUShadowClusterInput) { + self.arguments = arguments + self.counts = counts + self.instances = instances + self.viewCount = viewCount + self.input = input + } + + /// Byte offset of the first indirect draw of one view and part. + public func argumentOffset(view: Int, part: Int) -> Int { + (view * input.clusters.count + Int(input.segments[part])) * MemoryLayout.stride + } + + /// Byte offset of the draw count of one view and part. + public func countOffset(view: Int, part: Int) -> Int { + (view * input.segments.count + part) * MemoryLayout.stride + } +} + +/// Frustum-culls meshlet clusters for many shadow views in one GPU pass and writes one indexed indirect draw per +/// surviving cluster, compacted per (view, part) with a GPU counter. The test is the one of +/// ``GPUShadowCullProcessor``, so it never rejects a cluster that reaches a view. +/// One owner (render node and camera) uses an instance; its frame ring is not shared. +public protocol GPUShadowClusterCullProcessor: AnyObject, Sendable { + /// Encodes culling before the shadow render pass on `command`, or returns nil (encoding nothing) when the + /// input is invalid or the output would exceed the processor's budget. + func encode(on command: any CommandBuffer, input: GPUShadowClusterInput, views: [GPUShadowCullView]) -> GPUShadowClusterCullOutput? +} + +/// Largest argument buffer of one cluster cull: about 3.3 million cluster-view pairs. +public enum GPUShadowClusterCullLimits { + public static let argumentBytes = 64 << 20 +} diff --git a/Sources/AdaRender/RenderGraph/RenderNode.swift b/Sources/AdaRender/RenderGraph/RenderNode.swift index 02af82f84..ba7c4a3ba 100644 --- a/Sources/AdaRender/RenderGraph/RenderNode.swift +++ b/Sources/AdaRender/RenderGraph/RenderNode.swift @@ -28,7 +28,10 @@ public protocol RenderNode: Sendable { /// Execute the graph node logic, issues draw calls, updates the output slots and optionally queues up subgraphs for execution. The graph data, input and output values are /// passed via the ``RenderGraphContext``. - @RenderGraphActor + /// + /// Nodes of one graph run one after another. Graphs of different render worlds may run at the same time + /// (see `RenderPipelining`), so a node must not share mutable state with nodes of other worlds except + /// through synchronized types such as the render device. func execute( context: inout Context, renderContext: RenderContext diff --git a/Sources/AdaRender/RenderWorldPlugin.swift b/Sources/AdaRender/RenderWorldPlugin.swift index 3fdd72df4..6d8f62ed3 100644 --- a/Sources/AdaRender/RenderWorldPlugin.swift +++ b/Sources/AdaRender/RenderWorldPlugin.swift @@ -119,7 +119,11 @@ public struct RenderSystem { // Because we can have a situation when we start task, but main thread already cleared surfaces. let windows = surfaces.windows do { - try await runRenderGraph(in: context.world) + if RenderPipelining.isActive(in: context.world) { + try await runPipelinedRenderGraph(in: context.world) + } else { + try await runRenderGraph(in: context.world) + } for window in windows { try window.currentDrawable?.present() } @@ -128,9 +132,22 @@ public struct RenderSystem { } } + /// A pipelined render world executes off the shared `RenderGraphActor`, so a serial world's graph + /// (for example the UI window) does not queue behind it. Its own updates never overlap: the parent + /// waits for the previous update before extracting the next frame. + @concurrent + private func runPipelinedRenderGraph(in world: World) async throws { + try await executeRenderGraph(in: world) + } + @RenderGraphActor @inline(__always) - private func runRenderGraph( + private func runRenderGraph(in world: World) async throws { + try await executeRenderGraph(in: world) + } + + @inline(__always) + private func executeRenderGraph( in world: World ) async throws { guard @@ -160,7 +177,18 @@ struct RenderWorldExctractor: WorldExctractor { if let primaryWindow = mainWorld.getResource(PrimaryWindowId.self) { renderWorld.insertResource(primaryWindow) } + // Copied, not read through `Extract`, because surfaces are created after extraction (`.prepare`). + if let frameRequests = mainWorld.getResource(WindowFrameRequests.self) { + renderWorld.insertResource(frameRequests) + } else { + renderWorld.removeResource(WindowFrameRequests.self) + } await renderWorld.runScheduler(.extract) + // A pipelined render world renders while the main world simulates the next frame. + // Reading the main world after extraction would race, so the reference ends here. + if RenderPipelining.isActive(in: renderWorld) { + renderWorld.removeResource(MainWorld.self) + } } } @@ -201,8 +229,8 @@ public func CreateWindowSurfaces( _ surfaces: ResMut, _ renderDevice: Res, _ renderInstance: Res, - _ primaryWindow: Extract>, - _ frameRequests: Extract> + _ primaryWindow: Res, + _ frameRequests: Res ) async { surfaces.windows.removeAll() // Keep this system in the camera dependency graph, but never acquire host drawables offscreen. @@ -210,7 +238,7 @@ public func CreateWindowSurfaces( return } let device = renderDevice.renderDevice - let requests = frameRequests.wrappedValue.wrappedValue + let requests = frameRequests.wrappedValue surfaces.wrappedValue.pendingFrameRequests.include(requests) do { @@ -228,7 +256,7 @@ public func CreateWindowSurfaces( } let ref: WindowRef = - if primaryWindow.wrappedValue.windowId == windowId { + if primaryWindow.wrappedValue?.windowId == windowId { .primary } else { .windowId(windowId) diff --git a/Sources/AdaRender/Shaders/ShaderCache.swift b/Sources/AdaRender/Shaders/ShaderCache.swift index 8e5bc9d6f..42c4cf16a 100644 --- a/Sources/AdaRender/Shaders/ShaderCache.swift +++ b/Sources/AdaRender/Shaders/ShaderCache.swift @@ -292,6 +292,31 @@ enum ShaderCache { } } + /// Forgets a stage whose changed source failed to compile. + /// + /// ``hasChanges(for:stage:version:includeSearchPaths:)`` records the new source hash before compiling. Without + /// this rollback a failed compile would leave the manifest current while the previous translation stays on disk, + /// and every later launch would silently reuse that stale translation. + static func invalidate(for source: ShaderSource, stage: ShaderStage) { + guard let fileURL = source.fileURL else { return } + let cacheKey = fileURL.prepareCachePath + cachedManifest.withLock { cacheData in + if cacheData == nil { cacheData = self.loadCacheData() } + guard cacheData?[cacheKey]?[stage] != nil else { return } + cacheData?[cacheKey]?[stage] = nil + if let cacheData { self.saveCacheData(cacheData) } + } + removeReflection(for: fileURL, stage: stage) + do { + let cacheFile = try self.getCacheDirectory() + .appending(path: cacheKey, directoryHint: .isDirectory) + .appending(path: "cache-\(stage.rawValue).device-compiled-shader.\(Constants.shaderCacheFileExtension)", directoryHint: .notDirectory) + if fileSystem.itemExists(at: cacheFile) { try fileSystem.removeItem(at: cacheFile) } + } catch { + logger.error("Failed to remove a stale device-compiled shader: \(error)") + } + } + static func removeReflection(for fileURL: URL, stage: ShaderStage) { let path = fileURL.prepareCachePath diff --git a/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift b/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift index ec3469466..79f740da8 100644 --- a/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift +++ b/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift @@ -156,6 +156,16 @@ public final class ShaderCompiler { } let version = self.getShaderVersion(for: stage) let hasChanges = ShaderCache.hasChanges(for: self.shaderSource, stage: stage, version: version, includeSearchPaths: self.includeSearchPaths) + do { + return try compileShader(for: stage, version: version, hasChanges: hasChanges) + } catch { + // The manifest already holds the new source hash: roll it back so the next launch recompiles. + if hasChanges { ShaderCache.invalidate(for: self.shaderSource, stage: stage) } + throw error + } + } + + private func compileShader(for stage: ShaderStage, version: Int, hasChanges: Bool) throws -> Shader { if !hasChanges { if let deviceCompiledShader = ShaderCache.getCachedDeviceCompiledShader(for: self.shaderSource, stage: stage), deviceCompiledShader.language == (unsafe RenderEngine.shared.type.deviceLang) { diff --git a/Sources/AdaRender/TemporalUpscaling.swift b/Sources/AdaRender/TemporalUpscaling.swift index 4792cf0fc..34654f40b 100644 --- a/Sources/AdaRender/TemporalUpscaling.swift +++ b/Sources/AdaRender/TemporalUpscaling.swift @@ -27,8 +27,53 @@ public struct TemporalUpscalingSettings: Codable, Equatable, Sendable { } } +/// Surface guides of a path-traced frame for denoising reconstruction (DLSS Ray Reconstruction), at input size. +public struct RayReconstructionGuides: Sendable { + /// RGBA16Float: diffuse albedo, specular albedo, and world-space normal with roughness in alpha. + public var diffuseAlbedo: Texture + public var specularAlbedo: Texture + public var normalRoughness: Texture + /// Unjittered world-to-view and view-to-clip transforms of the frame. + public var worldToView: Transform3D + public var viewToClip: Transform3D + /// The tracer restarted its history (scene, light set, settings or denoiser change): reset reconstruction too. + public var reset: Bool + + public init( + diffuseAlbedo: Texture, + specularAlbedo: Texture, + normalRoughness: Texture, + worldToView: Transform3D, + viewToClip: Transform3D, + reset: Bool = false + ) { + self.diffuseAlbedo = diffuseAlbedo + self.specularAlbedo = specularAlbedo + self.normalRoughness = normalRoughness + self.worldToView = worldToView + self.viewToClip = viewToClip + self.reset = reset + } +} + /// Backend-owned history. Create one instance per source camera, never share it between views. public protocol TemporalUpscaler: AnyObject, Sendable { + /// Whether ``encodeDenoised(commandBuffer:color:depth:motion:reactive:guides:output:jitter:reset:)`` can succeed. + var supportsDenoising: Bool { get } + + /// Denoises and reconstructs a noisy path-traced frame; false means the caller must use ``encode`` instead. + func encodeDenoised( + commandBuffer: CommandBuffer, + color: Texture, + depth: Texture, + motion: Texture, + reactive: Texture, + guides: RayReconstructionGuides, + output: Texture, + jitter: Vector2, + reset: Bool + ) -> Bool + /// Encodes reconstruction; false means the caller must present a spatial fallback. func encode( commandBuffer: CommandBuffer, @@ -41,3 +86,19 @@ public protocol TemporalUpscaler: AnyObject, Sendable { reset: Bool ) -> Bool } + +extension TemporalUpscaler { + public var supportsDenoising: Bool { false } + + public func encodeDenoised( + commandBuffer _: CommandBuffer, + color _: Texture, + depth _: Texture, + motion _: Texture, + reactive _: Texture, + guides _: RayReconstructionGuides, + output _: Texture, + jitter _: Vector2, + reset _: Bool + ) -> Bool { false } +} diff --git a/Sources/AdaScene/3D/Model3DExtractionCache.swift b/Sources/AdaScene/3D/Model3DExtractionCache.swift index 481855b43..914b3f76b 100644 --- a/Sources/AdaScene/3D/Model3DExtractionCache.swift +++ b/Sources/AdaScene/3D/Model3DExtractionCache.swift @@ -26,6 +26,8 @@ struct Model3DExtractionCache: Resource { private var seen: [Bool] = [] private var materialOwners: [Key: RID] = [:] private var uniqueMaterials: [RID: Material] = [:] + /// Every extracted mesh, indexed by ``Entry/index``; ``ExtractedMesh3DSources`` publishes views of it. + private var storage: [Mesh3DRenderSource] = [] private(set) var geometryBuildCount = 0 mutating func extract( @@ -40,17 +42,22 @@ struct Model3DExtractionCache: Resource { skins: Query, morphs: Query, terrainMasks: Query, + pathTracingRoles: Query? = nil, sources: inout ExtractedMesh3DSources, animatedBounds: inout AnimatedBounds3D, skinning: inout Skinning3DUniforms, morphUniforms: inout Morph3DUniforms, materials: inout PBR3DUniforms, device: RenderDevice, - drawPass: Model3DDrawPass + drawPass: Model3DDrawPass, + snapshotsMaterials: Bool = false ) { + // Drop the published share first, so in-place updates of `storage` do not copy the whole array. + sources.meshes = [] + sources.pathTracingProxies = [] if worldID != sourceWorldID { entries.removeAll(keepingCapacity: true) - sources.meshes.removeAll(keepingCapacity: true) + storage.removeAll(keepingCapacity: true) seen.removeAll(keepingCapacity: true) materialOwners.removeAll(keepingCapacity: true) uniqueMaterials.removeAll(keepingCapacity: true) @@ -102,6 +109,22 @@ struct Model3DExtractionCache: Resource { if !hidden.isEmpty { hierarchy.batched().forEach { entity, relationship in parents[entity.id] = relationship.parent } } + var roleComponents: [Entity.ID: PathTracingGeometry3D] = [:] + pathTracingRoles?.batched().forEach { entity, role in roleComponents[entity.id] = role } + if !roleComponents.isEmpty, parents.isEmpty { + hierarchy.batched().forEach { entity, relationship in parents[entity.id] = relationship.parent } + } + var resolvedRoles: [Entity.ID: PathTracingGeometry3D?] = [:] + func role(_ id: Entity.ID) -> PathTracingGeometry3D? { + if roleComponents.isEmpty { return nil } + if let value = resolvedRoles[id] { return value } + // A provisional value also bounds malformed cyclic hierarchies. + resolvedRoles[id] = .some(nil) + let value = roleComponents[id] ?? parents[id].flatMap(role) + resolvedRoles[id] = .some(value) + return value + } + var roles = false var resolvedHidden: [Entity.ID: Bool] = [:] func isHidden(_ id: Entity.ID) -> Bool { if hidden.isEmpty { @@ -134,22 +157,25 @@ struct Model3DExtractionCache: Resource { let palette = skinning.buffer(for: entity.id) let forceVisible = forced.contains(entity.id) let matrix = global.matrix + let pathTracing = role(entity.id) + if pathTracing != nil { roles = true } if let entry = entries[key], entry.isStatic, palette == nil, morphByEntity[entity.id] == nil, + storage[entry.index].pathTracing == pathTracing, entry.meshRevision == component.extractionRevision, entry.lodRevision == lod?.extractionRevision, entry.terrainMaskRevision == mask?.revision { let index = entry.index seen[index] = true // Do not write unchanged array elements: render nodes retain the last array snapshot. // A camera-only frame can consequently keep the entire source buffer without a COW copy. - if sources.meshes[index].transform != matrix { - sources.meshes[index].previousTransform = sources.meshes[index].transform - sources.meshes[index].transform = matrix - sources.meshes[index].bounds = entry.localBounds.map { MeshVisibility3DMath.transformed($0, by: matrix) } - } else if sources.meshes[index].previousTransform != matrix { + if storage[index].transform != matrix { + storage[index].previousTransform = storage[index].transform + storage[index].transform = matrix + storage[index].bounds = entry.localBounds.map { MeshVisibility3DMath.transformed($0, by: matrix) } + } else if storage[index].previousTransform != matrix { // Motion vectors settle on the frame after a mesh stops moving. - sources.meshes[index].previousTransform = matrix + storage[index].previousTransform = matrix } - if sources.meshes[index].forceVisible != forceVisible { sources.meshes[index].forceVisible = forceVisible } + if storage[index].forceVisible != forceVisible { storage[index].forceVisible = forceVisible } return } @@ -174,9 +200,9 @@ struct Model3DExtractionCache: Resource { bounds = AABB(min: current.min - growth, max: current.max + growth) } } - let index = entries[key]?.index ?? sources.meshes.count - let previous = index < sources.meshes.count ? sources.meshes[index].transform : matrix - let source = Mesh3DRenderSource( + let index = entries[key]?.index ?? storage.count + let previous = index < storage.count ? storage[index].transform : matrix + var source = Mesh3DRenderSource( entity: entity.id, drawPass: drawPass, mesh: mesh, @@ -196,11 +222,12 @@ struct Model3DExtractionCache: Resource { morphBuffers: morphBuffers, skinningOffset: skinning.packedOffset(for: entity.id) ) - if index == sources.meshes.count { - sources.meshes.append(source) + source.pathTracing = pathTracing + if index == storage.count { + storage.append(source) seen.append(true) } else { - sources.meshes[index] = source + storage[index] = source seen[index] = true } entries[key] = Entry( @@ -223,21 +250,24 @@ struct Model3DExtractionCache: Resource { let matrix = global.matrix let lod = lodByEntity[entity.id] let forced = forced.contains(entity.id) - if let entry = entries[key], entry.meshRevision == component.extractionRevision, entry.lodRevision == lod?.extractionRevision { + let pathTracing = role(entity.id) + if pathTracing != nil { roles = true } + if let entry = entries[key], entry.meshRevision == component.extractionRevision, entry.lodRevision == lod?.extractionRevision, + storage[entry.index].pathTracing == pathTracing { let index = entry.index seen[index] = true - if sources.meshes[index].transform != matrix { - sources.meshes[index].previousTransform = sources.meshes[index].transform - sources.meshes[index].transform = matrix - sources.meshes[index].bounds = entry.localBounds.map { MeshVisibility3DMath.transformed($0, by: matrix) } - } else if sources.meshes[index].previousTransform != matrix { - sources.meshes[index].previousTransform = matrix + if storage[index].transform != matrix { + storage[index].previousTransform = storage[index].transform + storage[index].transform = matrix + storage[index].bounds = entry.localBounds.map { MeshVisibility3DMath.transformed($0, by: matrix) } + } else if storage[index].previousTransform != matrix { + storage[index].previousTransform = matrix } if entry.settlesInstances { - sources.meshes[index].previousInstances = sources.meshes[index].instances + storage[index].previousInstances = storage[index].instances entries[key]?.settlesInstances = false } - if sources.meshes[index].forceVisible != forced { sources.meshes[index].forceVisible = forced } + if storage[index].forceVisible != forced { storage[index].forceVisible = forced } return } var prototypeBounds = component.mesh.bounds @@ -247,9 +277,9 @@ struct Model3DExtractionCache: Resource { let bounds = MeshVisibility3DMath.transformed(prototypeBounds, by: instance.transform) if let current = localBounds { localBounds = AABB(min: min(current.min, bounds.min), max: max(current.max, bounds.max)) } else { localBounds = bounds } } - let index = entries[key]?.index ?? sources.meshes.count - let previousTransform = index < sources.meshes.count ? sources.meshes[index].transform : matrix - let oldInstances = index < sources.meshes.count ? sources.meshes[index].instances : nil + let index = entries[key]?.index ?? storage.count + let previousTransform = index < storage.count ? storage[index].transform : matrix + let oldInstances = index < storage.count ? storage[index].instances : nil var previousByID: [UInt64: Transform3D] = [:] for instance in oldInstances ?? [] { previousByID[instance.id] = instance.transform } let previousInstances = component.instances.map { instance -> Mesh3DInstance in @@ -276,7 +306,8 @@ struct Model3DExtractionCache: Resource { ) source.instances = component.instances source.previousInstances = previousInstances - if index == sources.meshes.count { sources.meshes.append(source); seen.append(true) } else { sources.meshes[index] = source; seen[index] = true } + source.pathTracing = pathTracing + if index == storage.count { storage.append(source); seen.append(true) } else { storage[index] = source; seen[index] = true } entries[key] = Entry( index: index, meshRevision: component.extractionRevision, @@ -309,10 +340,10 @@ struct Model3DExtractionCache: Resource { if let firstMissing = seen.firstIndex(of: false) { var destination = firstMissing for index in firstMissing..>, _ terrainMasks: Extract>, + _ pathTracingRoles: Extract>, _ morphUniforms: ResMut, _ sources: ResMut, _ animatedBounds: ResMut, @@ -131,7 +132,8 @@ func ExtractModel3D( _ drawPass: Res, _ cache: ResMut, _ mainWorld: Res, - _ clock: Res + _ clock: Res, + _ pipelining: Res ) { terrainUniforms.wrappedValue.beginFrame() cache.wrappedValue.extract( @@ -146,17 +148,33 @@ func ExtractModel3D( skins: skins.wrappedValue, morphs: morphs.wrappedValue, terrainMasks: terrainMasks.wrappedValue, + pathTracingRoles: pathTracingRoles.wrappedValue, sources: &sources.wrappedValue, animatedBounds: &animatedBounds.wrappedValue, skinning: &skinningUniforms.wrappedValue, morphUniforms: &morphUniforms.wrappedValue, materials: &materialUniforms.wrappedValue, device: renderDevice.renderDevice, - drawPass: drawPass.wrappedValue + drawPass: drawPass.wrappedValue, + snapshotsMaterials: pipelining.wrappedValue?.isActive == true ) } public final class Model3DDrawPass: MeshletGeometryDrawPass, @unchecked Sendable { + /// The path-tracing surface variant writes both optional outputs, so it needs both targets attached. + static func writesPathTracingSurface(_ target: RenderViewTarget?) -> Bool { + guard let target else { return false } + return writesPathTracingSurface( + active: target.pathTracingSurfaceActive, + albedo: target.diffuseSurface3DTexture != nil, + emission: target.reflectionResponse3DTexture != nil + ) + } + + static func writesPathTracingSurface(active: Bool, albedo: Bool, emission: Bool) -> Bool { + active && albedo && emission + } + private static let flatNormalTexture = Texture2D( image: Image(width: 1, height: 1, color: Color(red: 0.5, green: 0.5, blue: 1)) ) @@ -202,6 +220,7 @@ public final class Model3DDrawPass: MeshletGeometryDrawPass, @unchecked Sendable features: (isMotion || isContact ? [] : hasLightmapUV ? shadingFeatures : shadingFeatures.subtracting(.lightmap)).union(morphed ? .morph : []) .union(!isMotion && !isContact && view.components[RenderViewTarget.self]?.reflectionResponse3DTexture != nil ? .hybridReflection : []) .union(!isMotion && !isContact && view.components[RenderViewTarget.self]?.diffuseSurface3DTexture != nil ? .diffuseSurface : []) + .union(!isMotion && !isContact && Self.writesPathTracingSurface(view.components[RenderViewTarget.self]) ? .pathTracingSurface : []) .union(!isMotion && !isContact && view.components[RenderViewTarget.self]?.irradianceGridActive == true ? .irradianceGrid : []) .union(edges ? .wireframe : []) ) diff --git a/Sources/CWebGPUDLSS/DLSSDevice.cpp b/Sources/CWebGPUDLSS/DLSSDevice.cpp index c592a00eb..37a248099 100644 --- a/Sources/CWebGPUDLSS/DLSSDevice.cpp +++ b/Sources/CWebGPUDLSS/DLSSDevice.cpp @@ -69,7 +69,38 @@ AdaDLSSDevice::~AdaDLSSDevice() { NVSDK_NGX_D3D12_Shutdown1(native.Get()); } } +bool AdaDLSSDevice::locateRuntime() { + std::wstring module(MAX_PATH, L'\0'); + const DWORD length = GetModuleFileNameW(nullptr, module.data(), static_cast(module.size())); + if (length == 0 || length >= module.size()) { lastError = "Cannot resolve the executable folder for the DLSS runtime"; return false; } + module.resize(length); + const auto executableFolder = std::filesystem::path(module).parent_path(); + std::error_code error; + if (std::filesystem::is_regular_file(executableFolder / L"nvngx_dlss.dll", error)) { + hasDenoiserRuntime = std::filesystem::is_regular_file(executableFolder / L"nvngx_dlssd.dll", error); + return true; + } + // Development builds (`swift run` of an example) may use the prepared SDK instead of a staged copy. + wchar_t* sdk = nullptr; + size_t sdkLength = 0; + if (_wdupenv_s(&sdk, &sdkLength, L"ADAENGINE_DLSS_SDK_PATH") == 0 && sdk) { + const auto folder = std::filesystem::path(sdk) / L"lib" / L"Windows_x86_64" / L"rel"; + free(sdk); + if (std::filesystem::is_regular_file(folder / L"nvngx_dlss.dll", error)) { + hasDenoiserRuntime = std::filesystem::is_regular_file(folder / L"nvngx_dlssd.dll", error); + runtimeFolders.push_back(folder.wstring()); + for (const auto& path : runtimeFolders) runtimeFolderPointers.push_back(path.c_str()); + return true; + } + } + // u8string never throws for characters outside the ANSI code page; nothing may throw across the C API. + lastError = "DLSS runtime nvngx_dlss.dll not found next to " + executableFolder.u8string() + + "; stage it with script/build_dlss.py --stage-runtime or set ADAENGINE_DLSS_SDK_PATH"; + return false; +} + bool AdaDLSSDevice::initialize() { + if (!locateRuntime()) return false; if (!webgpu.HasFeature(wgpu::FeatureName::SharedTextureMemoryD3D12Resource) || !webgpu.HasFeature(wgpu::FeatureName::SharedFenceDXGISharedHandle)) { lastError = "Dawn D3D12 shared texture/fence features are unavailable"; @@ -91,15 +122,24 @@ bool AdaDLSSDevice::initialize() { auto logs = std::filesystem::temp_directory_path(error) / L"AdaEngine" / L"NGX"; std::filesystem::create_directories(logs, error); if (error) { lastError = "NGX log directory is unavailable"; return false; } + NVSDK_NGX_FeatureCommonInfo featureInfo{}; + featureInfo.PathListInfo.Path = runtimeFolderPointers.empty() ? nullptr : runtimeFolderPointers.data(); + featureInfo.PathListInfo.Length = static_cast(runtimeFolderPointers.size()); const auto result = NVSDK_NGX_D3D12_Init_with_ProjectID(projectID, NVSDK_NGX_ENGINE_TYPE_CUSTOM, - "AdaEngine", logs.c_str(), native.Get()); + "AdaEngine", logs.c_str(), native.Get(), &featureInfo, NVSDK_NGX_Version_API); if (!ada_dlss_check_ngx(result, "NGX initialization")) return false; initialized = true; if (!ada_dlss_check_ngx(NVSDK_NGX_D3D12_GetCapabilityParameters(&capabilities), "DLSS capabilities")) return false; int supported = 0, driverUpdate = 0; capabilities->Get(NVSDK_NGX_Parameter_SuperSampling_Available, &supported); capabilities->Get(NVSDK_NGX_Parameter_SuperSampling_NeedsUpdatedDriver, &driverUpdate); - if (!supported || driverUpdate) { lastError = "DLSS is unavailable on the selected adapter/driver"; return false; } + if (driverUpdate) { lastError = "DLSS needs a newer NVIDIA driver"; return false; } + if (!supported) { lastError = "DLSS Super Resolution is not supported by the selected NVIDIA adapter"; return false; } + int denoising = 0, denoisingDriverUpdate = 0; + capabilities->Get(NVSDK_NGX_Parameter_SuperSamplingDenoising_Available, &denoising); + capabilities->Get(NVSDK_NGX_Parameter_SuperSamplingDenoising_NeedsUpdatedDriver, &denoisingDriverUpdate); + supportsRayReconstruction = denoising && !denoisingDriverUpdate && hasDenoiserRuntime; + if (denoising && !hasDenoiserRuntime) fprintf(stderr, "AdaEngine DLSS: Ray Reconstruction runtime nvngx_dlssd.dll not found\n"); if (!ada_dlss_check(native->CreateFence(0, D3D12_FENCE_FLAG_SHARED, IID_PPV_ARGS(&fence)), "Create shared fence")) return false; HANDLE handle = nullptr; if (!ada_dlss_check(native->CreateSharedHandle(fence.Get(), nullptr, GENERIC_ALL, nullptr, &handle), "Share fence")) return false; diff --git a/Sources/CWebGPUDLSS/DLSSInterop.h b/Sources/CWebGPUDLSS/DLSSInterop.h index df5940365..8ce5d00ec 100644 --- a/Sources/CWebGPUDLSS/DLSSInterop.h +++ b/Sources/CWebGPUDLSS/DLSSInterop.h @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -26,7 +28,16 @@ struct AdaDLSSDevice { std::mutex mutex; uint64_t serial = 0; bool initialized = false; + /// Ray Reconstruction (NGX "DLSS-D") is available in addition to Super Resolution. + bool supportsRayReconstruction = false; + /// `nvngx_dlssd.dll` sits next to `nvngx_dlss.dll`. + bool hasDenoiserRuntime = false; + /// Extra folders NGX searches for its runtime DLLs; NGX may read them whenever it creates a feature. + std::vector runtimeFolders; + std::vector runtimeFolderPointers; ~AdaDLSSDevice(); + /// Finds `nvngx_dlss.dll` beside the executable or in the prepared SDK; false with `lastError` set otherwise. + bool locateRuntime(); bool initialize(); bool wait(uint64_t value); bool idle(); @@ -56,6 +67,9 @@ struct AdaDLSSView { AdaDLSSDevice& device; uint32_t width, height, outputWidth, outputHeight; AdaDLSSSharedTexture color, depth, motion, bias, output; + /// Ray Reconstruction guides, allocated only when `rayReconstruction` is set. + AdaDLSSSharedTexture diffuseAlbedo, specularAlbedo, normals; + bool rayReconstruction = false; std::array packedViews; wgpu::Texture boundDepth, boundMotion, boundReactive; wgpu::BindGroup packGroup; @@ -73,4 +87,10 @@ struct AdaDLSSView { AdaDLSSFrame& frame); bool evaluate(WGPUTexture inputColor, WGPUTexture inputDepth, WGPUTexture inputMotion, WGPUTexture reactive, WGPUTexture destination, float jitterX, float jitterY, bool reset); + /// Ray Reconstruction; the guides are copied into shared textures before NGX reads them. + bool evaluateDenoised(WGPUTexture inputColor, WGPUTexture inputDepth, WGPUTexture inputMotion, + WGPUTexture reactive, WGPUTexture diffuse, WGPUTexture specular, WGPUTexture normalRoughness, + WGPUTexture destination, float jitterX, float jitterY, const float* worldToView, const float* viewToClip, bool reset); + bool copyGuides(WGPUTexture diffuse, WGPUTexture specular, WGPUTexture normalRoughness, AdaDLSSFrame& frame); + }; diff --git a/Sources/CWebGPUDLSS/DLSSView.cpp b/Sources/CWebGPUDLSS/DLSSView.cpp index 9bc196bee..c818f1d05 100644 --- a/Sources/CWebGPUDLSS/DLSSView.cpp +++ b/Sources/CWebGPUDLSS/DLSSView.cpp @@ -1,10 +1,11 @@ #include "DLSSInterop.h" +#include #include #include AdaDLSSView::~AdaDLSSView() { // A failed preparation may have acquired only a subset of the textures. - for (auto* texture : {&color, &depth, &motion, &bias, &output}) { + for (auto* texture : {&color, &depth, &motion, &bias, &output, &diffuseAlbedo, &specularAlbedo, &normals}) { texture->end(device, outputWaitFences); } device.idle(); @@ -25,11 +26,17 @@ bool AdaDLSSView::initialize() { wgpu::TextureFormat::R8Unorm, rendered, D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET) || !output.create(device, outputWidth, outputHeight, DXGI_FORMAT_R16G16B16A16_FLOAT, wgpu::TextureFormat::RGBA16Float, sampled | wgpu::TextureUsage::CopySrc, D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS)) return false; + if (rayReconstruction) { + for (auto* guide : {&diffuseAlbedo, &specularAlbedo, &normals}) { + if (!guide->create(device, width, height, DXGI_FORMAT_R16G16B16A16_FLOAT, wgpu::TextureFormat::RGBA16Float, + sampled | wgpu::TextureUsage::CopyDst, D3D12_RESOURCE_FLAG_NONE)) return false; + } + } packedViews = {depth.texture.CreateView(), motion.texture.CreateView(), bias.texture.CreateView()}; outputWaitFences.reserve(1); if (!ada_dlss_check_ngx(NVSDK_NGX_D3D12_AllocateParameters(¶meters), "DLSS parameter allocation")) return false; for (auto& frame : frames) { - frame.waitFences.reserve(6); + frame.waitFences.reserve(9); if (!ada_dlss_check(device.native->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&frame.allocator)), "DLSS command allocator") || !ada_dlss_check(device.native->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, @@ -175,6 +182,149 @@ bool AdaDLSSView::evaluate(WGPUTexture inputColor, WGPUTexture inputDepth, WGPUT return true; } +bool AdaDLSSView::copyGuides(WGPUTexture diffuse, WGPUTexture specular, WGPUTexture normalRoughness, AdaDLSSFrame& frame) { + AdaDLSSSharedTexture* targets[] = {&diffuseAlbedo, &specularAlbedo, &normals}; + WGPUTexture sources[] = {diffuse, specular, normalRoughness}; + for (auto* target : targets) if (!target->begin(device, lastSignal)) return false; + auto encoder = device.webgpu.CreateCommandEncoder(); + wgpu::Extent3D extent{width, height, 1}; + for (int i = 0; i < 3; ++i) { + wgpu::TexelCopyTextureInfo source, destination; + source.texture = wgpu::Texture(sources[i]); + destination.texture = targets[i]->texture; + encoder.CopyTextureToTexture(&source, &destination, &extent); + } + auto commands = encoder.Finish(); + device.webgpu.GetQueue().Submit(1, &commands); + for (auto* target : targets) if (!target->end(device, frame.waitFences)) return false; + return true; +} + +bool AdaDLSSView::evaluateDenoised(WGPUTexture inputColor, WGPUTexture inputDepth, WGPUTexture inputMotion, + WGPUTexture reactive, WGPUTexture diffuse, WGPUTexture specular, WGPUTexture normalRoughness, + WGPUTexture destination, float jitterX, float jitterY, const float* worldToView, const float* viewToClip, bool reset) { + if (!valid || !rayReconstruction || !inputColor || !inputDepth || !inputMotion || !reactive || !diffuse || !specular + || !normalRoughness || !destination || !worldToView || !viewToClip || !std::isfinite(jitterX) || !std::isfinite(jitterY)) return false; + for (auto texture : {inputColor, inputDepth, inputMotion, reactive, diffuse, specular, normalRoughness}) { + if (wgpuTextureGetWidth(texture) != width || wgpuTextureGetHeight(texture) != height) return false; + } + for (auto texture : {diffuse, specular, normalRoughness}) { + if (wgpuTextureGetFormat(texture) != WGPUTextureFormat_RGBA16Float) return false; + } + if (wgpuTextureGetWidth(destination) != outputWidth || wgpuTextureGetHeight(destination) != outputHeight + || wgpuTextureGetFormat(inputColor) != WGPUTextureFormat_RGBA16Float + || wgpuTextureGetFormat(destination) != WGPUTextureFormat_RGBA16Float + || wgpuTextureGetFormat(inputDepth) != WGPUTextureFormat_Depth32Float) return false; + auto& frame = frames[evaluated % frames.size()]; + if (!device.wait(frame.completion)) return false; + frame.waitFences.clear(); + frame.waitFences.swap(outputWaitFences); + if (!pack(inputColor, inputDepth, inputMotion, reactive, frame) || !copyGuides(diffuse, specular, normalRoughness, frame)) { + valid = false; + return false; + } + if (!ada_dlss_check(frame.allocator->Reset(), "Reset DLSS-RR allocator") + || !ada_dlss_check(frame.commands->Reset(frame.allocator.Get(), nullptr), "Reset DLSS-RR commands")) return false; + if (!feature) { + NVSDK_NGX_DLSSD_Create_Params create{}; + create.InDenoiseMode = NVSDK_NGX_DLSS_Denoise_Mode_DLUnified; + create.InRoughnessMode = NVSDK_NGX_DLSS_Roughness_Mode_Packed; + create.InUseHWDepth = NVSDK_NGX_DLSS_Depth_Type_HW; + create.InWidth = width; + create.InHeight = height; + create.InTargetWidth = outputWidth; + create.InTargetHeight = outputHeight; + const float scale = float(width) / outputWidth; + create.InPerfQualityValue = scale >= 0.99f ? NVSDK_NGX_PerfQuality_Value_DLAA + : scale >= 0.6f ? NVSDK_NGX_PerfQuality_Value_MaxQuality + : scale >= 0.55f ? NVSDK_NGX_PerfQuality_Value_Balanced : NVSDK_NGX_PerfQuality_Value_MaxPerf; + create.InFeatureCreateFlags = NVSDK_NGX_DLSS_Feature_Flags_IsHDR | NVSDK_NGX_DLSS_Feature_Flags_MVLowRes + | NVSDK_NGX_DLSS_Feature_Flags_AutoExposure; + fprintf(stderr, "AdaEngine DLSS: creating Ray Reconstruction feature\n"); + if (!ada_dlss_check_ngx(NGX_D3D12_CREATE_DLSSD_EXT(frame.commands.Get(), 1, 1, &feature, parameters, &create), + "Create DLSS Ray Reconstruction feature")) { valid = false; return false; } + } + std::array resources{color.native.Get(), depth.native.Get(), motion.native.Get(), bias.native.Get(), + diffuseAlbedo.native.Get(), specularAlbedo.native.Get(), normals.native.Get(), output.native.Get()}; + std::array barriers{}; + for (size_t i = 0; i < barriers.size(); ++i) { + barriers[i].Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barriers[i].Transition.pResource = resources[i]; + barriers[i].Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + barriers[i].Transition.StateBefore = D3D12_RESOURCE_STATE_COMMON; + barriers[i].Transition.StateAfter = i == 7 ? D3D12_RESOURCE_STATE_UNORDERED_ACCESS : D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE; + } + frame.commands->ResourceBarrier(static_cast(barriers.size()), barriers.data()); + std::array view{}, clip{}; + std::copy(worldToView, worldToView + 16, view.begin()); + std::copy(viewToClip, viewToClip + 16, clip.begin()); + NVSDK_NGX_D3D12_DLSSD_Eval_Params evaluate{}; + evaluate.pInDiffuseAlbedo = diffuseAlbedo.native.Get(); + evaluate.pInSpecularAlbedo = specularAlbedo.native.Get(); + evaluate.pInNormals = normals.native.Get(); + evaluate.pInColor = color.native.Get(); + evaluate.pInOutput = output.native.Get(); + evaluate.pInDepth = depth.native.Get(); + evaluate.pInMotionVectors = motion.native.Get(); + evaluate.pInBiasCurrentColorMask = bias.native.Get(); + evaluate.InMVScaleX = float(width); + evaluate.InMVScaleY = float(height); + evaluate.InJitterOffsetX = jitterX; + evaluate.InJitterOffsetY = jitterY; + evaluate.InReset = reset || evaluated == 0; + evaluate.InRenderSubrectDimensions = {width, height}; + evaluate.pInWorldToViewMatrix = view.data(); + evaluate.pInViewToClipMatrix = clip.data(); + evaluate.InPreExposure = 1.0f; + evaluate.InExposureScale = 1.0f; + if (!ada_dlss_check_ngx(NGX_D3D12_EVALUATE_DLSSD_EXT(frame.commands.Get(), feature, parameters, &evaluate), "Evaluate DLSS Ray Reconstruction")) { + valid = false; + return false; + } + for (auto& barrier : barriers) std::swap(barrier.Transition.StateBefore, barrier.Transition.StateAfter); + frame.commands->ResourceBarrier(static_cast(barriers.size()), barriers.data()); + if (!ada_dlss_check(frame.commands->Close(), "Close DLSS-RR evaluation")) return false; + ID3D12CommandList* lists[] = {frame.commands.Get()}; + device.queue->ExecuteCommandLists(1, lists); + lastSignal = ++device.serial; + if (!ada_dlss_check(device.queue->Signal(device.fence.Get(), lastSignal), "Signal DLSS-RR completion")) return false; + frame.completion = lastSignal; + output.initialized = true; + if (!output.begin(device, lastSignal)) { valid = false; return false; } + auto copy = device.webgpu.CreateCommandEncoder(); + wgpu::TexelCopyTextureInfo source, target; + source.texture = output.texture; + target.texture = wgpu::Texture(destination); + wgpu::Extent3D extent{outputWidth, outputHeight, 1}; + copy.CopyTextureToTexture(&source, &target, &extent); + auto copied = copy.Finish(); + device.webgpu.GetQueue().Submit(1, &copied); + if (!output.end(device, outputWaitFences)) { valid = false; return false; } + ++evaluated; + if (evaluated == 1) fprintf(stderr, "AdaEngine DLSS: Ray Reconstruction evaluated %ux%u -> %ux%u\n", width, height, outputWidth, outputHeight); + return true; +} + +extern "C" bool ada_dlss_device_supports_ray_reconstruction(AdaDLSSDevice* device) { + return device && device->supportsRayReconstruction; +} +extern "C" AdaDLSSView* ada_dlss_rr_view_create(AdaDLSSDevice* device, uint32_t width, uint32_t height, uint32_t ow, uint32_t oh) { + if (!device || !device->supportsRayReconstruction) return nullptr; + std::lock_guard lock(device->mutex); + auto view = std::make_unique(*device, width, height, ow, oh); + view->rayReconstruction = true; + return view->initialize() ? view.release() : nullptr; +} +extern "C" bool ada_dlss_rr_evaluate(AdaDLSSView* view, void* color, void* depth, void* motion, void* reactive, + void* diffuse, void* specular, void* normalRoughness, void* output, float jitterX, float jitterY, + const float* worldToView, const float* viewToClip, bool reset) { + if (!view) return false; + std::lock_guard lock(view->device.mutex); + return view->evaluateDenoised(static_cast(color), static_cast(depth), static_cast(motion), + static_cast(reactive), static_cast(diffuse), static_cast(specular), + static_cast(normalRoughness), static_cast(output), jitterX, jitterY, worldToView, viewToClip, reset); +} + extern "C" AdaDLSSView* ada_dlss_view_create(AdaDLSSDevice* device, uint32_t width, uint32_t height, uint32_t ow, uint32_t oh) { if (!device) return nullptr; std::lock_guard lock(device->mutex); diff --git a/Sources/CWebGPUDLSS/include/AdaWebGPUDLSS.h b/Sources/CWebGPUDLSS/include/AdaWebGPUDLSS.h index 4c71aa4e7..f3a92927c 100644 --- a/Sources/CWebGPUDLSS/include/AdaWebGPUDLSS.h +++ b/Sources/CWebGPUDLSS/include/AdaWebGPUDLSS.h @@ -24,6 +24,16 @@ bool ada_dlss_evaluate(AdaDLSSView* view, void* color, void* depth, float jitter_x, float jitter_y, bool reset); uint64_t ada_dlss_evaluated_frames(AdaDLSSView* view); +// DLSS Ray Reconstruction (NGX "DLSS-D"): denoises and upscales a path-traced frame. +bool ada_dlss_device_supports_ray_reconstruction(AdaDLSSDevice* device); +AdaDLSSView* ada_dlss_rr_view_create(AdaDLSSDevice* device, uint32_t input_width, + uint32_t input_height, uint32_t output_width, uint32_t output_height); +// Guides are RGBA16Float at input size: diffuse albedo, specular albedo, and world normal with roughness in w. +// Matrices are 16 floats, row-major (row vectors applied from the left), as NGX expects. +bool ada_dlss_rr_evaluate(AdaDLSSView* view, void* color, void* depth, void* motion, void* reactive, + void* diffuse_albedo, void* specular_albedo, void* normal_roughness, void* output, + float jitter_x, float jitter_y, const float* world_to_view, const float* view_to_clip, bool reset); + #ifdef __cplusplus } #endif diff --git a/Tests/AdaEngineTests/GPUShadowBatchingSceneTests.swift b/Tests/AdaEngineTests/GPUShadowBatchingSceneTests.swift new file mode 100644 index 000000000..e468d9586 --- /dev/null +++ b/Tests/AdaEngineTests/GPUShadowBatchingSceneTests.swift @@ -0,0 +1,156 @@ +#if canImport(WebGPU) && (os(Windows) || os(macOS)) + @_spi(Internal) @testable import AdaApp + @testable import AdaCorePipelines + import AdaECS + @_spi(Internal) @testable import AdaRender + import AdaScene + import AdaTransform + import AdaUtils + import Foundation + import Math + import Testing + + extension Render3DGPUTests { + /// GPU-culled shadow casters must produce exactly the frames of the CPU-culled path. + @MainActor + @Suite("WebGPU GPU-driven shadow batching", .serialized) + struct GPUShadowBatchingSceneTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_WEBGPU_SCENE_SMOKE"] == "1")) + func matchesCPUShadowsAcrossLightAndCasterMotion() async throws { + unsafe RenderEngine.configurations.preferredBackend = .webgpu + try RenderEngine.setupRenderEngine() + let engine = try #require(unsafe RenderEngine.shared) + try #require(engine.type == .webgpu) + let device = engine.renderDevice + let app = AppWorlds(main: World()) + app.insertResource(OffscreenRenderWorld()) + .insertResource(PrimaryWindowId(windowId: RID())) + .addPlugin(MainSchedulerPlugin()) + .addPlugin(TransformPlugin()) + .addPlugin(RenderWorldPlugin()) + .addPlugin(CameraPlugin()) + .addPlugin(AssetsPlugin(filePath: #filePath)) + .addPlugin(VisibilityPlugin()) + .addPlugin(ScenePlugin()) + .addPlugin(Core3DPlugin()) + try await app.build() + + let target = RenderTexture(size: [160, 120], scaleFactor: 1, format: .bgra8) + var environment = Environment3D(antiAliasing: .none) + environment.skybox.isEnabled = false + environment.screenSpaceReflection.isEnabled = false + environment.shadows.isEnabled = true + environment.shadows.cascadeCount = 3 + environment.localShadows.isEnabled = true + environment.localShadows.maximumLights = 2 + let camera = app.main.spawn("Camera", bundle: Camera3D( + camera: Camera(renderTarget: target), + transform: Transform(rotation: Quat.euler([-0.5, 0, 0]), position: [0, 5, -9]), + environment: environment + )) + let floorMaterial = PBRMaterial() + floorMaterial.baseColorFactor = [0.7, 0.7, 0.7, 1] + app.main.spawn("Floor") { + Mesh3DComponent(mesh: Mesh.generateCube(size: [16, 0.2, 16], renderDevice: device), materials: [floorMaterial]) + Transform(position: [0, -1, 0]) + } + let singleSided = PBRMaterial() + singleSided.doubleSided = false + singleSided.baseColorFactor = [0.8, 0.3, 0.1, 1] + let doubleSided = PBRMaterial() + doubleSided.doubleSided = true + let masked = PBRMaterial() + masked.alphaMode = .mask + masked.alphaCutoff = 0.5 + masked.baseColorFactor = [0.2, 0.5, 0.9, 0.4] + let cube = Mesh.generateCube(size: [0.8, 0.8, 0.8], renderDevice: device) + let sphere = Mesh.generateSphere(segments: 12, rings: 8, renderDevice: device) + var movers: [Entity] = [] + for index in 0..<6 { + let material = [singleSided, doubleSided, masked][index % 3] + let entity = app.main.spawn("Caster \(index)") { + Mesh3DComponent(mesh: index.isMultiple(of: 2) ? cube : sphere, materials: [material]) + Transform(position: [Float(index % 3 - 1) * 2.5, 0.5, Float(index / 3) * 2.5]) + } + movers.append(entity) + } + // A caster with level-of-detail meshes and a draw distance stays CPU-culled and shares the faces. + app.main.spawn("CPU-path caster") { + Mesh3DComponent(mesh: sphere, materials: [singleSided]) + MeshLOD3DComponent(alternatives: [cube], maximumDistance: 60) + Transform(position: [1, 1.5, 1.5]) + } + app.main.spawn("Instanced casters") { + Mesh3DInstancesComponent(mesh: cube, materials: [singleSided], instances: (0..<5).map { + Mesh3DInstance(id: UInt64($0), transform: Transform3D(translation: [Float($0) * 1.4 - 2.8, 0, -3]), color: [1, 1, 1, 0.9]) + }) + Transform() + } + app.main.spawn("Sun") { + DirectionalLightComponent(intensity: 3, castShadows: true) + Transform(rotation: Quat.euler([-0.9, 0.4, 0])) + } + let lights = (0..<2).map { index in + app.main.spawn("Point \(index)") { + PointLightComponent(intensity: 25, castShadows: true, range: 12) + Transform(position: [Float(index) * 4 - 2, 3, 1]) + } + } + let render = try #require(app.getSubworldBuilder(by: .renderWorld)?.main) + func frame(gpu: Bool) async throws -> Image { + var registry = try #require(render.getResource(ShadowCasterRegistry3D.self)) + registry.isEnabled = gpu + render.insertResource(registry) + for _ in 0..<3 { try await app.update() } + if gpu { + let current = try #require(render.getResource(ShadowCasterRegistry3D.self)) + #expect(current.hasCasters, "The scene's static casters must take the GPU path") + #expect(!current.cpuSources.isEmpty, "The LOD caster must stay on the CPU path in the same passes") + } + return try #require(try await device.readImage(from: target)) + } + // A frame without shadows sits between the compared frames, so a dropped GPU submission + // (which leaves the previous image in the target) cannot pass as a match. + func unshadowed() async throws -> Image { + var settings = try #require(camera.components[Environment3D.self]) + let original = settings + settings.shadows.isEnabled = false + settings.localShadows.isEnabled = false + camera.components[Environment3D.self] = settings + for _ in 0..<2 { try await app.update() } + let image = try #require(try await device.readImage(from: target)) + camera.components[Environment3D.self] = original + return image + } + func compare(_ scenario: String) async throws { + let reference = try await frame(gpu: false) + let plain = try await unshadowed() + #expect(plain.data != reference.data, "\(scenario) must contain visible shadows") + let actual = try await frame(gpu: true) + let differing = zip(reference.data, actual.data).filter { $0 != $1 }.count + #expect(actual.data == reference.data, "GPU shadow batching changed \(differing) bytes in \(scenario)") + if let directory = ProcessInfo.processInfo.environment["ADAENGINE_WEBGPU_SCENE_OUTPUT"] { + let folder = URL(fileURLWithPath: directory) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try reference.writePNG(to: folder.appendingPathComponent("\(scenario)-cpu-shadows.png")) + try actual.writePNG(to: folder.appendingPathComponent("\(scenario)-gpu-shadows.png")) + } + } + try await compare("static") + for (index, light) in lights.enumerated() { + light.components[Transform.self] = Transform(position: [Float(index) * 3 - 1, 2.5, 2]) + } + try await compare("lights-moved") + movers[1].components[Transform.self] = Transform(position: [4, 0.5, 4]) + try await compare("caster-moved") + + // Kept faces must redraw where a GPU-path caster changed, with the GPU path staying on. + _ = try await frame(gpu: true) + movers[0].components[Transform.self] = Transform(position: [-3, 0.5, 3]) + let incremental = try await frame(gpu: true) + let full = try await frame(gpu: false) + #expect(incremental.data == full.data, "A partly reused atlas must equal a full redraw") + } + } + } +#endif diff --git a/Tests/AdaEngineTests/LocalLighting3DTests.swift b/Tests/AdaEngineTests/LocalLighting3DTests.swift index 1af764ecd..448b48ace 100644 --- a/Tests/AdaEngineTests/LocalLighting3DTests.swift +++ b/Tests/AdaEngineTests/LocalLighting3DTests.swift @@ -45,6 +45,50 @@ struct LocalLighting3DMathTests { #expect(abs(clip.x) < 0.001 && abs(clip.y) < 0.001 && clip.z > 0 && clip.z < clip.w) } + @Test + func shadowFaceBoundsContainEverythingTheFaceCanSee() { + let id = World().spawn().id + let light = ExtractedLocalLight3D(entity: id, kind: .point, position: [2, 3, 4], range: 6) + var generator = SystemRandomNumberGenerator() + for _ in 0..<2_000 { + let direction = Vector3(Float.random(in: -1...1, using: &generator), Float.random(in: -1...1, using: &generator), + Float.random(in: -1...1, using: &generator)) + guard direction.length > 0.01 else { continue } + let point = light.position + direction.normalized * Float.random(in: 0...light.range, using: &generator) + let bounds = LocalLighting3DMath.shadowFaceBounds(light, face: LocalLighting3DMath.pointFace(direction: direction)) + #expect(point.x >= bounds.min.x - 1e-4 && point.x <= bounds.max.x + 1e-4) + #expect(point.y >= bounds.min.y - 1e-4 && point.y <= bounds.max.y + 1e-4) + #expect(point.z >= bounds.min.z - 1e-4 && point.z <= bounds.max.z + 1e-4) + } + let spot = ExtractedLocalLight3D(entity: id, kind: .spot, position: [0, 4, 0], direction: [0, -1, 0], range: 10, outerConeAngle: 35) + let spotBounds = LocalLighting3DMath.shadowFaceBounds(spot, face: 0) + #expect(spotBounds.min == Vector3(-10, -6, -10) && spotBounds.max == Vector3(10, 14, 10)) + } + + @Test + func onlyFacesThatVisibleOrIrradianceLitSurfacesCanSampleAreRedrawn() { + let id = World().spawn().id + let projection = Transform3D.perspective(fieldOfView: .degrees(60), aspectRatio: 1, zNear: 0.1, zFar: 100) + let frustum = MeshVisibilityFrustum3D(viewProjection: projection) + // Independent of the forward-axis convention: exactly one of two mirrored lights is in front of the camera. + let lights = [Float(20), -20].map { ExtractedLocalLight3D(entity: id, kind: .point, position: [0, 0, $0], range: 2) } + let sampled = lights.map { light in + (0..<6).filter { LocalLighting3DMath.shadowFaceIsSampled( + LocalLighting3DMath.shadowFaceBounds(light, face: $0), viewFrustum: frustum, irradianceBounds: nil) } + } + #expect(sampled.filter { $0.count == 6 }.count == 1) + #expect(sampled.filter(\.isEmpty).count == 1) + let hidden = lights[sampled[0].isEmpty ? 0 : 1] + let bounce = AABB(center: hidden.position, halfExtents: [1, 1, 1]) + for face in 0..<6 { + #expect(LocalLighting3DMath.shadowFaceIsSampled( + LocalLighting3DMath.shadowFaceBounds(hidden, face: face), viewFrustum: frustum, irradianceBounds: bounce)) + } + let far = AABB(center: [500, 500, 500], halfExtents: [1, 1, 1]) + #expect(!LocalLighting3DMath.shadowFaceIsSampled( + LocalLighting3DMath.shadowFaceBounds(hidden, face: 0), viewFrustum: frustum, irradianceBounds: far)) + } + @Test func perCameraSelectionIsBoundedAndShadowSlotsSurviveRankingChanges() { let world = World() diff --git a/Tests/AdaEngineTests/Model3DIncrementalExtractionTests.swift b/Tests/AdaEngineTests/Model3DIncrementalExtractionTests.swift index 4ebbb363e..44cf60715 100644 --- a/Tests/AdaEngineTests/Model3DIncrementalExtractionTests.swift +++ b/Tests/AdaEngineTests/Model3DIncrementalExtractionTests.swift @@ -215,6 +215,43 @@ struct Model3DIncrementalExtractionTests { #expect(try sources(render).first?.transform.origin.x == 15) } + @Test("Path-tracing roles split raster meshes from tracer-only proxies, inherited from ancestors") + func pathTracingRolesSplitSources() async throws { + let (app, render) = try await makeApp() + let mesh = cube(), material = PBRMaterial() + let detailed = app.main.spawn { Transform(); PathTracingGeometry3D.rasterOnly } + let proxyRoot = app.main.spawn { Transform(); PathTracingGeometry3D.pathTracingOnly } + let plain = app.main.spawn { Mesh3DComponent(mesh: mesh, materials: [material]); GlobalTransform(matrix: .identity) } + let child = app.main.spawn { Mesh3DComponent(mesh: mesh, materials: [material]); GlobalTransform(matrix: .identity) } + let proxy = app.main.spawn { Mesh3DComponent(mesh: mesh, materials: [material]); GlobalTransform(matrix: .identity) } + detailed.addChild(child) + proxyRoot.addChild(proxy) + await render.runScheduler(.extract) + let extracted = try #require(render.getResource(ExtractedMesh3DSources.self)) + // Raster passes see the plain and the raster-only mesh, never the proxy. + #expect(Set(extracted.meshes.map(\.entity)) == [plain.id, child.id]) + #expect(extracted.pathTracingProxies.map(\.entity) == [proxy.id]) + // The tracer sees the plain mesh and the proxy, never the raster-only detail. + #expect(Set(extracted.pathTracedMeshes.map(\.entity)) == [plain.id, proxy.id]) + + // Removing the role rebuilds the cached source and returns the proxy to raster. + proxyRoot.components.remove(PathTracingGeometry3D.self) + await render.runScheduler(.extract) + let restored = try #require(render.getResource(ExtractedMesh3DSources.self)) + #expect(Set(restored.meshes.map(\.entity)) == [plain.id, child.id, proxy.id]) + #expect(restored.pathTracingProxies.isEmpty) + } + + @Test("Without path-tracing roles the published raster list is the tracer's list") + func withoutRolesTracerSeesRasterList() async throws { + let (app, render) = try await makeApp() + _ = app.main.spawn { Mesh3DComponent(mesh: cube(), materials: [PBRMaterial()]); GlobalTransform(matrix: .identity) } + await render.runScheduler(.extract) + let extracted = try #require(render.getResource(ExtractedMesh3DSources.self)) + #expect(extracted.pathTracingProxies.isEmpty) + #expect(extracted.pathTracedMeshes.map(\.entity) == extracted.meshes.map(\.entity)) + } + private func makeApp(core3D: Bool = false) async throws -> (AppWorlds, World) { if unsafe RenderEngine.shared == nil { unsafe RenderEngine.configurations.preferredBackend = .headless diff --git a/Tests/AdaEngineTests/PipelinedRenderingTests.swift b/Tests/AdaEngineTests/PipelinedRenderingTests.swift new file mode 100644 index 000000000..dd0d57948 --- /dev/null +++ b/Tests/AdaEngineTests/PipelinedRenderingTests.swift @@ -0,0 +1,96 @@ +import AdaApp +import AdaECS +import Testing + +@Suite("Pipelined subworld updates") +struct PipelinedRenderingTests { + private static let renderName = AppWorldName(rawValue: "PipelinedRender") + + @MainActor + private static func makeApp(pipelined: Bool?) async throws -> (app: AppWorlds, render: AppWorlds) { + let app = AppWorlds(main: World()) + app.updateScheduler = .update + app.insertResource(PipelineTick()) + app.addSystem(PipelineTickSystem.self) + let render = AppWorlds(main: World()) + render.updateScheduler = .update + render.insertResource(PipelineRenderCount()) + render.setExctractor(PipelineTickExtractor()) + render.addSystem(SlowPipelineRenderSystem.self) + if let pipelined { render.insertResource(RenderPipelining(isEnabled: pipelined)) } + app.addSubworld(render, by: renderName) + try await app.build() + return (app, render) + } + + @Test("A pipelined subworld renders while the parent continues") + @MainActor + func parentDoesNotWaitForThePipelinedRender() async throws { + let (app, render) = try await Self.makeApp(pipelined: true) + try await app.update() + // The parent frame finished before the slow render system did. + #expect(render.main.getResource(PipelineRenderCount.self)?.value == 0) + try await app.waitForPipelinedUpdates() + #expect(render.main.getResource(PipelineRenderCount.self)?.value == 1) + #expect(render.main.getResource(PipelineExtractedTick.self)?.value == 1) + } + + @Test("The next extraction waits for the previous render") + @MainActor + func extractionIsTheOnlyMeetingPoint() async throws { + let (app, render) = try await Self.makeApp(pipelined: true) + try await app.update() + try await app.update() + // Frame 1 completed before frame 2 was extracted; frame 2 is still in flight. + #expect(render.main.getResource(PipelineRenderCount.self)?.value == 1) + #expect(render.main.getResource(PipelineExtractedTick.self)?.value == 2) + try await app.waitForPipelinedUpdates() + #expect(render.main.getResource(PipelineRenderCount.self)?.value == 2) + } + + @Test("Tooling access waits for the render in flight") + @MainActor + func worldAccessIsASafePoint() async throws { + let (app, render) = try await Self.makeApp(pipelined: true) + try await app.update() + let count = try await app.withWorldAccess { render.main.getResource(PipelineRenderCount.self)?.value } + #expect(count == 1) + } + + @Test("Subworlds stay serial unless they opt in", arguments: [nil, false] as [Bool?]) + @MainActor + func serialIsTheDefault(setting: Bool?) async throws { + let (app, render) = try await Self.makeApp(pipelined: setting) + #expect(!render.isUpdatePipelined) + try await app.update() + #expect(render.main.getResource(PipelineRenderCount.self)?.value == 1) + } +} + +private struct PipelineTick: Resource { var value = 0 } +private struct PipelineRenderCount: Resource { var value = 0 } +private struct PipelineExtractedTick: Resource { let value: Int } + +@PlainSystem +struct PipelineTickSystem { + @ResMut private var tick + init(world _: World) {} + func update(context _: UpdateContext) { tick.value += 1 } +} + +@PlainSystem +struct SlowPipelineRenderSystem { + @ResMut private var count + init(world _: World) {} + func update(context _: UpdateContext) async { + // Long enough that the parent frame clearly finishes first. + try? await Task.sleep(for: .milliseconds(150)) + count.value += 1 + } +} + +private struct PipelineTickExtractor: WorldExctractor { + func exctract(from mainWorld: World, to world: World) async { + world.insertResource(PipelineExtractedTick(value: mainWorld.getResource(PipelineTick.self)?.value ?? -1)) + } +} diff --git a/Tests/AdaEngineTests/Render3DPathTracingTests.swift b/Tests/AdaEngineTests/Render3DPathTracingTests.swift index 1213feee8..4155c88fb 100644 --- a/Tests/AdaEngineTests/Render3DPathTracingTests.swift +++ b/Tests/AdaEngineTests/Render3DPathTracingTests.swift @@ -3,7 +3,7 @@ import AdaAssets @testable import AdaCorePipelines import AdaECS @_spi(Internal) @testable import AdaRender -import AdaScene +@testable import AdaScene import AdaTransform import AdaUtils import Foundation @@ -45,6 +45,29 @@ struct Render3DPathTracingSettingsTests { #expect(invalid.sanitized.textureTileSize == 512) } + @Test func denoiserSettingsDefaultRoundTripAndSanitize() throws { + let decoded = try JSONDecoder().decode(PathTracing3D.self,from:Data("{\"isEnabled\":true}".utf8)) + #expect(decoded.denoiser == .automatic && decoded.minimumHistoryWeight == 1.0 / 32) + var settings = PathTracing3D(isEnabled:true) + settings.denoiser = .rayReconstruction + settings.minimumHistoryWeight = 0.25 + #expect(try JSONDecoder().decode(PathTracing3D.self,from:JSONEncoder().encode(settings)) == settings) + settings.minimumHistoryWeight = .nan + #expect(settings.sanitized.minimumHistoryWeight == 1.0 / 32) + settings.minimumHistoryWeight = 0 + #expect(settings.sanitized.minimumHistoryWeight == 0.005) + } + + @Test func pathTracingSurfaceVariantNeedsBothOutputs() { + // Without both outputs attached the variant would write undeclared fragment outputs and fail to compile. + #expect(Model3DDrawPass.writesPathTracingSurface(active:true,albedo:true,emission:true)) + #expect(!Model3DDrawPass.writesPathTracingSurface(active:true,albedo:true,emission:false)) + #expect(!Model3DDrawPass.writesPathTracingSurface(active:true,albedo:false,emission:true)) + #expect(!Model3DDrawPass.writesPathTracingSurface(active:false,albedo:true,emission:true)) + #expect(!Model3DDrawPass.writesPathTracingSurface(nil)) + #expect(PBRMaterial.ShaderFeatures.pathTracingSurface.enablingMacros == ["ADA_PATH_TRACING_SURFACE"]) + } + @Test func stacklessBVHCoversEveryTriangleAndHasValidEscapes() { let input: [PathTracing3DBVH.Triangle] = (0..<39).map { index in let x = Float(index % 7) - 4, z = Float(index / 7) - 3 @@ -136,6 +159,104 @@ extension Render3DGPUTests { #expect(world.getResource(PathTracing3DViews.self)?.entries[scene.camera.id] == nil) } + @Test(.enabled(if: GoldenFrame.activeBackend != nil)) + func lightTableIsReusedUntilLightsChange() async throws { + let scene = try await Self.scene() + _ = Self.receiver(scene) + let lamp = scene.app.main.spawn("Table lamp") { PointLightComponent(intensity:80,range:20); Transform(position:[0,1,-2]) } + _ = try await scene.render(.none, frames: 4) + let world = try #require(scene.app.getSubworldBuilder(by: .renderWorld)?.main) + let entry = try #require(world.getResource(PathTracing3DViews.self)?.entries[scene.camera.id]) + let table = try #require(entry.lightTable) + #expect(entry.lightTableBuilds == 1) + _ = try await scene.render(.none, frames: 3) + let retained = try #require(world.getResource(PathTracing3DViews.self)?.entries[scene.camera.id]) + #expect(retained.lightTableBuilds == 1) + #expect(retained.lightTable === table) + // A changed light gets a fresh table, never an in-place rewrite of one a frame in flight may read. + lamp.components[PointLightComponent.self] = PointLightComponent(intensity:40,range:20) + _ = try await scene.render(.none, frames: 1) + let changed = try #require(world.getResource(PathTracing3DViews.self)?.entries[scene.camera.id]) + #expect(changed.lightTableBuilds == 2) + #expect(changed.lightTable !== table) + } + + @Test(.enabled(if: GoldenFrame.activeBackend != nil)) + func portableHistorySurvivesMotionButRestartsWhenALightChanges() async throws { + let scene = try await Self.scene() + _ = Self.receiver(scene) + try scene.configure { $0.pathTracing.denoiser = .portable } + let lamp = scene.app.main.spawn("Moving lamp") { PointLightComponent(intensity:80,range:20); Transform(position:[0,1,-2]) } + _ = try await scene.render(.none, frames: 4) + let key = String(scene.camera.id) + let before = try #require(Self.stats(scene).snapshots[key]) + #expect(before.denoiser == PathTracingDenoiser.portable.rawValue && before.failure == nil) + // Reprojection keeps history through camera and light motion. + lamp.components[Transform.self] = Transform(position:[0.3,1,-2]) + if var transform = scene.camera.components[Transform.self] { + transform.position += Vector3(0.05, 0, 0) + scene.camera.components[Transform.self] = transform + } + _ = try await scene.render(.none, frames: 3) + #expect(try #require(Self.stats(scene).snapshots[key]).historyResets == before.historyResets) + // Retuning a light restarts it, so a switched-off or dimmed lamp never lingers. + lamp.components[PointLightComponent.self] = PointLightComponent(intensity:20,range:20) + _ = try await scene.render(.none, frames: 1) + #expect(try #require(Self.stats(scene).snapshots[key]).historyResets == before.historyResets + 1) + } + + @Test(.enabled(if: GoldenFrame.activeBackend != nil)) + func referenceAccumulationRestartsOnCameraMotion() async throws { + let scene = try await Self.scene() + _ = Self.receiver(scene) + try scene.configure { $0.pathTracing.denoiser = .off } + _ = try await scene.render(.none, frames: 3) + let key = String(scene.camera.id) + let before = try #require(Self.stats(scene).snapshots[key]) + #expect(before.denoiser == PathTracingDenoiser.off.rawValue) + if var transform = scene.camera.components[Transform.self] { + transform.position += Vector3(0.05, 0, 0) + scene.camera.components[Transform.self] = transform + } + // The moved transform reaches the extracted camera within two frames. + _ = try await scene.render(.none, frames: 2) + #expect(try #require(Self.stats(scene).snapshots[key]).historyResets > before.historyResets) + } + + @Test(.enabled(if: GoldenFrame.activeBackend != nil)) + func portableOutputIsFiniteAndConvergesTowardReference() async throws { + let scene = try await Self.scene() + let pbr = Self.receiver(scene) + pbr.baseColorFactor = [0.9, 0.2, 0.1, 1] + _ = scene.app.main.spawn("Lamp") { PointLightComponent(intensity:80,range:20); Transform(position:[0,1,-2]) } + try scene.configure { $0.pathTracing.denoiser = .portable } + let portable = try await scene.render(.none, frames: 12) + try scene.configure { $0.pathTracing.denoiser = .off } + let reference = try await scene.render(.none, frames: 16) + for y in stride(from: 0, to: 480, by: 16) { + for x in stride(from: 0, to: 640, by: 16) { + let pixel = portable.getPixel(x: x, y: y) + #expect(pixel.red.isFinite && pixel.green.isFinite && pixel.blue.isFinite) + } + } + // Demodulation keeps the surface colour; the denoised light matches the converged reference closely. + let denoised = Self.red(portable), converged = Self.red(reference) + #expect(converged > 0.05) + #expect(abs(denoised - converged) < max(0.05, converged * 0.15)) + } + + @Test(.enabled(if: GoldenFrame.activeBackend != nil)) + func unavailableRayReconstructionFallsBackToPortable() async throws { + let scene = try await Self.scene() + _ = Self.receiver(scene) + // The test camera has no DLSS temporal upscaling, so Ray Reconstruction cannot run. + try scene.configure { $0.pathTracing.denoiser = .rayReconstruction } + _ = try await scene.render(.none, frames: 2) + let status = try #require(Self.stats(scene).snapshots[String(scene.camera.id)]) + #expect(status.denoiser == PathTracingDenoiser.portable.rawValue) + #expect(status.denoiserFallback != nil) + } + @Test(.enabled(if: GoldenFrame.activeBackend != nil)) func offscreenDisplayBackpressureKeepsCameraTargetsAndHistory() async throws { let scene = try await Self.scene() diff --git a/Tests/AdaEngineTests/Render3DSponzaBenchmarkTests.swift b/Tests/AdaEngineTests/Render3DSponzaBenchmarkTests.swift index 7e501a1ae..93ce837e1 100644 --- a/Tests/AdaEngineTests/Render3DSponzaBenchmarkTests.swift +++ b/Tests/AdaEngineTests/Render3DSponzaBenchmarkTests.swift @@ -35,6 +35,12 @@ extension Render3DGPUTests { var listPreparationMilliseconds: [Double] var mainNodeMilliseconds: [Double] var gpuPasses: [Render3DPerformanceMetrics.Sample] + /// Shadow-casting point lights that follow the camera (`ADAENGINE_SPONZA_FLYING_LIGHTS`), and per measured + /// frame the local shadow faces drawn, kept from earlier frames, and skipped as unsampled. + var flyingLights = 0 + var renderedShadowFaces: [Int] = [] + var reusedShadowFaces: [Int] = [] + var culledShadowFaces: [Int] = [] } struct Report: Codable { @@ -69,20 +75,43 @@ extension Render3DGPUTests { @discardableResult private static func setPose( - _ scene: MeshletBaselineFixtures.Scene, route: SponzaBenchmarkData.Route, sample: Int, count: Int + _ scene: MeshletBaselineFixtures.Scene, route: SponzaBenchmarkData.Route, sample: Int, count: Int, lights: [Entity] = [] ) -> Transform { let transform = route.transform(sample: sample, count: count) scene.camera.components[Transform.self] = transform // This benchmark controls an unparented camera outside the scheduler. CameraSystem runs // in preUpdate, before postUpdate transform propagation: publish this frame's world pose. scene.camera.components[GlobalTransform.self] = GlobalTransform(matrix: transform.matrix) + // Flying lights circle the camera, deterministically per route sample, so every face matrix changes + // each frame like a light carried through the scene. + for (index, light) in lights.enumerated() { + let angle = Float(sample) * 0.05 + Float(index) * 2 * .pi / Float(lights.count) + let position = transform.position + Vector3(Math.cos(angle) * 2.5, 0.8, Math.sin(angle) * 2.5) + light.components[Transform.self] = Transform(position: position) + light.components[GlobalTransform.self] = GlobalTransform(matrix: Transform(position: position).matrix) + } return transform } + private static func spawnFlyingLights(_ scene: MeshletBaselineFixtures.Scene, count: Int) -> [Entity] { + guard count > 0 else { return [] } + var environment = scene.camera.components[Environment3D.self] ?? Environment3D() + environment.localShadows.isEnabled = true + environment.localShadows.maximumLights = count + scene.camera.components[Environment3D.self] = environment + return (0.. Image { - let pose = setPose(scene, route: route, sample: frame, count: samples) + let pose = setPose(scene, route: route, sample: frame, count: samples, lights: lights) try await scene.app.update() await Baseline.drain(scene.device) let camera = try #require(scene.camera.components[Camera.self]) @@ -109,25 +138,35 @@ extension Render3DGPUTests { var environment = try #require(scene.camera.components[Environment3D.self]) environment.gpuOcclusionEnabled = hiZ scene.camera.components[Environment3D.self] = environment - setPose(scene, route: route, sample: 0, count: samples) + let lights = spawnFlyingLights(scene, count: try integer("FLYING_LIGHTS", fallback: 0, range: 0...4)) + defer { for light in lights { scene.app.main.removeEntity(light) } } + setPose(scene, route: route, sample: 0, count: samples, lights: lights) let window = GoldenFrame.activeBackend == .webgpu ? min(frames, 90) : max(1, unsafe RenderEngine.configurations.maxFramesInFlight) for frame in 0..() for _ in 0.. 5 }, "Only the moved caster's old and new bounds are reported") + #expect(changed.contains { abs($0.center.x - 10) < 0.01 } && changed.contains { abs($0.center.x - 20) < 0.01 }) + } + + @Test("without an identity palette every caster keeps the CPU path") + func missingPaletteKeepsCPUPath() throws { + let device = try Self.device() + let cube = Mesh.generateCube(renderDevice: device) + var registry = Self.registry() + var materials = PBR3DUniforms() + let sources = [Self.source(1, mesh: cube, material: PBRMaterial(), at: .zero)] + registry.update(sources: sources, materials: &materials, identityPalette: nil, device: device) + #expect(!registry.hasCasters) + #expect(registry.cpuSources.map(\.entity) == [1]) + } + + @Test("added, removed and no-longer-casting casters report their bounds; material changes regroup") + func reportsMembershipAndMaterialChanges() throws { + let device = try Self.device() + let cube = Mesh.generateCube(renderDevice: device) + let material = PBRMaterial() + let palette = device.createUniformBuffer(length: 64, binding: Skinning3DUniforms.binding) + var registry = Self.registry() + var materials = PBR3DUniforms() + func update(_ sources: [Mesh3DRenderSource]) { + materials.beginFrame() + registry.update(sources: sources, materials: &materials, identityPalette: palette, device: device) + } + let first = Self.source(1, mesh: cube, material: material, at: [0, 0, 0]) + update([first]) + update([first, Self.source(2, mesh: cube, material: material, at: [30, 0, 0])]) + #expect(try #require(registry.changedBounds).allSatisfy { abs($0.center.x - 30) < 0.01 }, "Only the added caster changed") + update([first]) + #expect(try #require(registry.changedBounds).allSatisfy { abs($0.center.x - 30) < 0.01 }, "Only the removed caster changed") + update([first, Self.source(3, mesh: cube, material: material, at: [-30, 0, 0], castShadows: false)]) + #expect(registry.input.casters.count == cube.models.reduce(0) { $0 + $1.parts.count }, "Non-casters are not registered") + + let revision = registry.revision + material.alphaMode = .mask + update([first]) + #expect(registry.revision == revision &+ 1) + #expect(registry.groups.allSatisfy { if case .masked = $0.key { true } else { false } }, "A masked material moves to a masked group") + #expect(try #require(registry.changedBounds).contains { abs($0.center.x) < 0.01 }) + } + + @Test("pools grow by rebuilding from the source buffers and keep every part addressable") + func poolsRebuildWhenFull() throws { + let device = try Self.device() + let material = PBRMaterial() + let palette = device.createUniformBuffer(length: 64, binding: Skinning3DUniforms.binding) + var registry = Self.registry() + var materials = PBR3DUniforms() + var sources: [Mesh3DRenderSource] = [] + func update() { + materials.beginFrame() + registry.update(sources: sources, materials: &materials, identityPalette: palette, device: device) + } + sources.append(Self.source(1, mesh: Mesh.generateCube(renderDevice: device), material: material, at: .zero)) + update() + let initial = registry.pooledBytes + #expect(initial > 0) + // Distinct meshes each need their own pooled geometry until the first allocation is full. + for index in 2...40 { + sources.append(Self.source(index, mesh: Mesh.generateSphere(segments: 24, rings: 16, renderDevice: device), material: material, at: [Float(index) * 3, 0, 0])) + } + update() + #expect(registry.pooledBytes > initial, "A full pool is reallocated larger") + #expect(registry.changedBounds == nil, "Rebuilt pools move every part, so every view redraws") + #expect(registry.isInputValid) + let parts = registry.input.parts + var ranges: Set<[UInt32]> = [] + for group in registry.groups { + for part in group.firstPart..<(group.firstPart + group.partCount) { + ranges.insert([UInt32(group.key.pool), parts[part].firstIndex]) + } + } + #expect(ranges.count == parts.count, "Pooled parts do not share index ranges within a pool") + let revision = registry.revision + update() + #expect(registry.revision == revision, "A steady scene neither rebuilds nor re-uploads") + } + + @Test("a scene over the pool limit keeps the CPU path without allocating pools every frame") + func overLimitScenesAllocateNothing() throws { + let device = try Self.device() + let material = PBRMaterial() + let palette = device.createUniformBuffer(length: 64, binding: Skinning3DUniforms.binding) + var registry = Self.registry() + registry.poolByteLimit = 1_024 + var materials = PBR3DUniforms() + let sources = [Self.source(1, mesh: Mesh.generateSphere(segments: 24, rings: 16, renderDevice: device), material: material, at: .zero)] + for _ in 0..<3 { + materials.beginFrame() + registry.update(sources: sources, materials: &materials, identityPalette: palette, device: device) + #expect(!registry.hasCasters) + #expect(registry.cpuSources.map(\.entity) == [1]) + } + #expect(registry.poolAllocations == 0) + #expect(registry.pooledBytes == 0) + } + + private static func registry() -> ShadowCasterRegistry3D { + var registry = ShadowCasterRegistry3D() + registry.isEnabled = true + registry.buildsWithoutDeviceSupport = true + return registry + } + + private static func source( + _ entity: Entity.ID, + mesh: Mesh, + material: Material, + at position: Vector3, + skinning: (any UniformBuffer)? = nil, + alternatives: [Mesh] = [], + maximumDistance: Float? = nil, + castShadows: Bool = true + ) -> Mesh3DRenderSource { + let transform = Transform3D(translation: position) + return Mesh3DRenderSource( + entity: entity, + drawPass: Model3DDrawPass(), + mesh: mesh, + materials: [material], + transform: transform, + bounds: MeshVisibility3DMath.transformed(mesh.bounds, by: transform), + alternatives: alternatives, + maximumDistance: maximumDistance, + castShadows: castShadows, + skinningBuffer: skinning + ) + } + + private static func device() throws -> RenderDevice { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } + return unsafe RenderEngine.shared.renderDevice + } +} diff --git a/Tests/AdaEngineTests/TerrainInstancingPaletteTests.swift b/Tests/AdaEngineTests/TerrainInstancingPaletteTests.swift index e51d7a84d..657c95246 100644 --- a/Tests/AdaEngineTests/TerrainInstancingPaletteTests.swift +++ b/Tests/AdaEngineTests/TerrainInstancingPaletteTests.swift @@ -121,6 +121,7 @@ private final class TerrainPaletteRecordingEncoder: RenderCommandEncoder { func setTriangleFillMode(_: TriangleFillMode) {} func setIndexBuffer(_: IndexBuffer, offset _: Int) {} func drawIndexed(indexCount _: Int, indexBufferOffset _: Int, instanceCount _: Int) { drawCount += 1 } + func drawIndexed(indexCount _: Int, indexBufferOffset _: Int, instanceCount _: Int, firstInstance _: Int) { drawCount += 1 } func draw(type _: IndexPrimitive, vertexStart _: Int, vertexCount _: Int, instanceCount _: Int) {} func endRenderPass() {} } diff --git a/Tests/AdaRenderTests/GPUShadowCullInputTests.swift b/Tests/AdaRenderTests/GPUShadowCullInputTests.swift new file mode 100644 index 000000000..a1e73f7b9 --- /dev/null +++ b/Tests/AdaRenderTests/GPUShadowCullInputTests.swift @@ -0,0 +1,61 @@ +@_spi(Internal) @testable import AdaRender +import Math +import Testing + +@Suite("GPU shadow cull input") +struct GPUShadowCullInputTests { + @Test("consistent inputs are valid") + func acceptsConsistentInput() { + #expect(Self.input().isValid) + } + + @Test("inputs that would write outside a segment or read outside the records are rejected") + func rejectsInconsistentInput() { + var input = Self.input() + input.segments = [0, 2] + #expect(!input.isValid, "Part 1 starts after part 0's single caster") + + input = Self.input() + input.casters[0].instance = 9 + #expect(!input.isValid, "Instance record out of range") + + input = Self.input() + input.casters[1] = GPUShadowCaster(bounds: nil, forceVisible: false, part: 5, instance: 1) + #expect(!input.isValid, "Part out of range") + + input = Self.input() + input.copiedBytes = input.instanceStride + 4 + #expect(!input.isValid, "Copy longer than a record") + + input = Self.input() + input.instances.removeLast() + #expect(!input.isValid, "Partial record") + } + + @Test("view planes match the rows of the view-projection") + func viewPlanes() { + let matrix = Transform3D.orthographic(left: -1, right: 1, top: 1, bottom: -1, zNear: 0, zFar: 1) + let view = GPUShadowCullView(viewProjection: matrix, culls: true) + #expect(view.plane0 == matrix.row(at: 3) + matrix.row(at: 0)) + #expect(view.plane5 == matrix.row(at: 3) - matrix.row(at: 2)) + #expect(view.culls == 1) + #expect(MemoryLayout.stride == 112) + #expect(MemoryLayout.stride == 48) + } + + private static func input() -> GPUShadowCullInput { + var input = GPUShadowCullInput() + let box = AABB(min: [-1, -1, -1], max: [1, 1, 1]) + input.casters = [ + GPUShadowCaster(bounds: box, forceVisible: false, part: 0, instance: 0), + GPUShadowCaster(bounds: box, forceVisible: false, part: 1, instance: 1), + GPUShadowCaster(bounds: nil, forceVisible: false, part: 1, instance: 2), + ] + input.instanceStride = 16 + input.copiedBytes = 16 + input.instances = [UInt8](repeating: 0, count: 48) + input.parts = [IndexedIndirectArguments(indexCount: 3), IndexedIndirectArguments(indexCount: 6)] + input.segments = [0, 1] + return input + } +} diff --git a/Tests/AdaRenderTests/PipelineDiskCacheTests.swift b/Tests/AdaRenderTests/PipelineDiskCacheTests.swift new file mode 100644 index 000000000..a0a190257 --- /dev/null +++ b/Tests/AdaRenderTests/PipelineDiskCacheTests.swift @@ -0,0 +1,233 @@ +@testable import AdaRender +import Foundation +import Testing + +@Suite("Pipeline disk cache") +struct PipelineDiskCacheTests { + private func makeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ada-pipeline-cache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test + func storedBlobIsLoadedByALaterCacheInstance() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let key = Data("pipeline-key".utf8) + let value = Data((0..<4096).map { UInt8(truncatingIfNeeded: $0 &* 31) }) + + PipelineDiskCache(directory: directory).store(key: key, value: value) + + let reopened = PipelineDiskCache(directory: directory) + #expect(reopened.load(key: key) == value) + #expect(reopened.statistics.hits == 1) + } + + @Test + func unknownKeyIsAMiss() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PipelineDiskCache(directory: directory) + + #expect(cache.load(key: Data("missing".utf8)) == nil) + #expect(cache.statistics.misses == 1) + } + + @Test + func entryWhoseStoredKeyDiffersIsRejected() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PipelineDiskCache(directory: directory) + let key = Data("first".utf8) + cache.store(key: key, value: Data([1, 2, 3])) + + // Simulate a file-name collision: another key's entry lands in this key's file. + let other = PipelineDiskCache(directory: directory) + let file = other.fileURL(for: key) + let foreign = PipelineDiskCache.encodeEntry(key: Data("second".utf8), value: Data([9])) + try foreign.write(to: file) + + #expect(other.load(key: key) == nil) + } + + @Test + func entryOfAnotherKeyIsKeptOnDisk() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PipelineDiskCache(directory: directory) + let key = Data("mine".utf8) + let file = cache.fileURL(for: key) + try PipelineDiskCache.encodeEntry(key: Data("theirs".utf8), value: Data([1])).write(to: file) + + #expect(cache.load(key: key) == nil) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test + func headerWithHugeLengthsIsRejectedWithoutTrapping() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PipelineDiskCache(directory: directory) + let key = Data("huge".utf8) + var entry = PipelineDiskCache.encodeEntry(key: key, value: Data([1, 2])) + entry.replaceSubrange(8..<16, with: Data(repeating: 0xFF, count: 8)) + try entry.write(to: cache.fileURL(for: key)) + + #expect(cache.load(key: key) == nil) + #expect(cache.statistics.rejected == 1) + } + + @Test + func copyUsesTheBlobSizedByTheQueryEvenIfTheFileChangesBetween() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let key = Data("raced".utf8) + let original = Data([1, 2, 3, 4]) + let cache = PipelineDiskCache(directory: directory) + cache.store(key: key, value: original) + + let size = key.withUnsafeBytes { cache.load(key: $0, into: nil) } + // Another process replaces the entry with a different size. + try PipelineDiskCache.encodeEntry(key: key, value: Data(repeating: 9, count: 64)).write(to: cache.fileURL(for: key)) + var output = [UInt8](repeating: 0, count: size) + let copied = key.withUnsafeBytes { keyBytes in + output.withUnsafeMutableBytes { cache.load(key: keyBytes, into: $0) } + } + + #expect(copied == original.count) + #expect(Data(output) == original) + } + + @Test + func concurrentStoresAndLoadsStayConsistent() async throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PipelineDiskCache(directory: directory) + await withTaskGroup(of: Void.self) { group in + for index in 0..<16 { + group.addTask { + let key = Data("key-\(index % 4)".utf8) + cache.store(key: key, value: Data(repeating: UInt8(index % 4), count: 256)) + _ = cache.load(key: key) + } + } + } + for index in 0..<4 { + #expect(cache.load(key: Data("key-\(index)".utf8)) == Data(repeating: UInt8(index), count: 256)) + } + } + + @Test + func unusedAdapterFoldersAndAbandonedTemporaryFilesAreRemoved() throws { + let root = try makeDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let manager = FileManager.default + let now = Date(timeIntervalSince1970: 2_000_000_000) + let old = now.addingTimeInterval(-60 * 24 * 60 * 60) + func makeFile(_ url: URL, date: Date) throws { + try Data([0]).write(to: url) + try manager.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } + let stale = root.appendingPathComponent("abc123", isDirectory: true) + let recent = root.appendingPathComponent("def456", isDirectory: true) + let foreign = root.appendingPathComponent("beef", isDirectory: true) + for folder in [stale, recent, foreign] { + try manager.createDirectory(at: folder, withIntermediateDirectories: true) + } + try makeFile(stale.appendingPathComponent("1-1.bin"), date: old) + try makeFile(recent.appendingPathComponent("2-1.bin"), date: now) + try makeFile(foreign.appendingPathComponent("notes.txt"), date: old) + let current = root.appendingPathComponent("0f0f", isDirectory: true) + try manager.createDirectory(at: current, withIntermediateDirectories: true) + try makeFile(current.appendingPathComponent("tmp-write"), date: old) + + _ = PipelineDiskCache(directory: current, now: now) + + #expect(!manager.fileExists(atPath: stale.path)) + #expect(manager.fileExists(atPath: recent.path)) + #expect(manager.fileExists(atPath: foreign.path)) + #expect(!manager.fileExists(atPath: current.appendingPathComponent("tmp-write").path)) + } + + @Test + func truncatedEntryIsRejectedAndRemoved() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let key = Data("pipeline".utf8) + let cache = PipelineDiskCache(directory: directory) + cache.store(key: key, value: Data(repeating: 7, count: 512)) + let file = cache.fileURL(for: key) + let bytes = try Data(contentsOf: file) + try bytes.prefix(bytes.count - 100).write(to: file) + + let reopened = PipelineDiskCache(directory: directory) + #expect(reopened.load(key: key) == nil) + #expect(!FileManager.default.fileExists(atPath: file.path)) + } + + @Test + func sizeQueryFollowedByCopyMatchesDawnsTwoStepLoad() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let key = Data("two-step".utf8) + let value = Data([10, 20, 30, 40, 50]) + PipelineDiskCache(directory: directory).store(key: key, value: value) + let cache = PipelineDiskCache(directory: directory) + + let size = key.withUnsafeBytes { cache.load(key: $0, into: nil) } + #expect(size == value.count) + var output = [UInt8](repeating: 0, count: size) + let copied = key.withUnsafeBytes { keyBytes in + output.withUnsafeMutableBytes { cache.load(key: keyBytes, into: $0) } + } + #expect(copied == value.count) + #expect(Data(output) == value) + // A buffer of the wrong size is refused rather than partially filled. + var small = [UInt8](repeating: 0, count: 2) + let refused = key.withUnsafeBytes { keyBytes in + small.withUnsafeMutableBytes { cache.load(key: keyBytes, into: $0) } + } + #expect(refused == 0) + } + + @Test + func directoryIsPerAdapterAndCanBeOverriddenOrDisabled() { + let caches = URL(fileURLWithPath: "/caches", isDirectory: true) + let first = PipelineDiskCache.directory(environment: [:], cachesDirectory: caches, adapterFingerprint: "d3d12|10de|2786") + let second = PipelineDiskCache.directory(environment: [:], cachesDirectory: caches, adapterFingerprint: "vulkan|10de|2786") + + #expect(first?.deletingLastPathComponent().path.hasSuffix("AdaEngine/PipelineCache") == true) + #expect(first != second) + let overridden = PipelineDiskCache.directory( + environment: ["ADAENGINE_PIPELINE_CACHE_DIR": "/custom"], + cachesDirectory: caches, + adapterFingerprint: "d3d12|10de|2786" + ) + #expect(overridden?.deletingLastPathComponent().lastPathComponent == "custom") + #expect(PipelineDiskCache.directory(environment: ["ADAENGINE_PIPELINE_CACHE": "0"], cachesDirectory: caches, adapterFingerprint: "x") == nil) + } + + @Test + func oldestEntriesArePrunedWhenTheDirectoryExceedsItsBudget() throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let writer = PipelineDiskCache(directory: directory) + for index in 0..<8 { + writer.store(key: Data("key-\(index)".utf8), value: Data(repeating: UInt8(index), count: 1000)) + let file = writer.fileURL(for: Data("key-\(index)".utf8)) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_000_000 + Double(index) * 60)], + ofItemAtPath: file.path + ) + } + + let pruned = PipelineDiskCache(directory: directory, byteBudget: 4500) + + #expect(pruned.load(key: Data("key-0".utf8)) == nil) + #expect(pruned.load(key: Data("key-7".utf8)) == Data(repeating: 7, count: 1000)) + let remaining = try FileManager.default.contentsOfDirectory(atPath: directory.path) + #expect(remaining.count <= 4) + } +} diff --git a/Tests/AdaRenderTests/PipelinedRenderWorldTests.swift b/Tests/AdaRenderTests/PipelinedRenderWorldTests.swift new file mode 100644 index 000000000..1997443ea --- /dev/null +++ b/Tests/AdaRenderTests/PipelinedRenderWorldTests.swift @@ -0,0 +1,56 @@ +import AdaApp +@_spi(Internal) import AdaECS +@_spi(Internal) @testable import AdaRender +import AdaUtils +import Testing + +@Suite("Pipelined render world isolation") +struct PipelinedRenderWorldTests { + @Test("A pipelined render world loses the main-world reference after extraction", arguments: [false, true]) + @MainActor + func mainWorldReferenceEndsAtExtraction(pipelined: Bool) async throws { + unsafe RenderEngine.configurations.preferredBackend = .headless + let app = AppWorlds(main: World(name: "PipelinedExtraction")) + app.addPlugin(RenderWorldPlugin()) + MainSchedulerPlugin().setup(in: app) + app.insertResource(PrimaryWindowId(windowId: RID())) + app.insertResource(OffscreenRenderWorld()) + // Plugins set up during build, so the render world exists only afterwards. + try await app.build() + let renderWorld = try #require(app.getSubworldBuilder(by: .renderWorld)) + renderWorld.insertResource(RenderPipelining(isEnabled: pipelined)) + + try await app.update() + try await app.waitForPipelinedUpdates() + #expect((renderWorld.main.getResource(MainWorld.self) != nil) == !pipelined) + } + + @Test("Material snapshots are reused until the material changes") + func materialSnapshotTracksRevisions() { + let material = PBRMaterial() + material.baseColorFactor = [0.2, 0.4, 0.6, 1] + material.roughnessFactor = 0.3 + let first = material.renderSnapshot() + #expect(first !== material) + #expect(first.snapshotSource === material) + #expect(first.baseColorFactor == material.baseColorFactor && first.roughnessFactor == 0.3) + #expect(material.renderSnapshot() === first) + + // Edits for the next frame never reach the snapshot that is being rendered. + material.roughnessFactor = 0.9 + #expect(first.roughnessFactor == 0.3) + let second = material.renderSnapshot() + #expect(second !== first) + #expect(second.roughnessFactor == 0.9) + } + + @Test("Every stored material property invalidates the snapshot") + func untrackedPropertiesStillInvalidate() { + let material = PBRMaterial() + let first = material.renderSnapshot() + // Not part of the PBR uniform block, so it does not change `uniformRevision`. + material.metallicFactor = 0.25 + #expect(material.renderSnapshot() !== first) + #expect(material.renderSnapshot().metallicFactor == 0.25) + } +} diff --git a/Tests/AdaRenderTests/WGPUBindingStateTests.swift b/Tests/AdaRenderTests/WGPUBindingStateTests.swift index e25ef9fc3..b69767cc3 100644 --- a/Tests/AdaRenderTests/WGPUBindingStateTests.swift +++ b/Tests/AdaRenderTests/WGPUBindingStateTests.swift @@ -127,6 +127,77 @@ struct WGPUBindingStateTests { #expect(image.getPixel(x: 7, y: 3) == .init(red: 1, green: 1, blue: 0, alpha: 1)) } + /// Shadow passes select each batch's instances with `firstInstance` instead of rebinding the + /// instance buffer at a new offset for every draw. + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_SPRITE_WGPU_SMOKE"] == "1")) + func indexedDrawsReadInstanceDataFromTheFirstInstance() async throws { + let device = try makeDevice() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("first-instance.glsl") + try """ + #version 450 core + #pragma stage : vert + layout(location=0) in vec4 a_Color; + layout(location=0) out vec4 v_Color; + [[main]] + void first_instance_vertex() { + vec2 p[3] = vec2[](vec2(-1,-1), vec2(3,-1), vec2(-1,3)); + v_Color = a_Color; + gl_Position = vec4(p[gl_VertexIndex], 0, 1); + } + #version 450 core + #pragma stage : frag + layout(location=0) in vec4 v_Color; + layout(location=0) out vec4 o_Color; + [[main]] + void first_instance_fragment() { o_Color = v_Color; } + """.write(to: source, atomically: true, encoding: .utf8) + let module = try ShaderCompiler(from: source).compileShaderModule() + var vertex = VertexDescriptor() + vertex.attributes[0] = .attribute(.vector4, name: "color", bufferIndex: 0, offset: 0) + vertex.layouts[0] = .init(stride: MemoryLayout.stride, stepFunction: .perInstance) + let pipeline = device.createRenderPipeline(from: RenderPipelineDescriptor( + vertex: try #require(module.getShader(for: .vertex)), + fragment: try module.getShader(for: .fragment), + debugName: "First instance", + backfaceCulling: false, + vertexDescriptor: vertex, + colorAttachments: [.init(format: .rgba8)] + )) + var colors: [Vector4] = [[1, 0, 0, 1], [0, 1, 0, 1], [0, 0, 1, 1], [1, 1, 0, 1]] + let instances = device.createVertexBuffer(label: "First instance colors", length: MemoryLayout.stride * colors.count, binding: 0) + unsafe colors.withUnsafeMutableBytes { unsafe instances.setData($0.baseAddress!, byteCount: $0.count, offset: 0) } + var indices: [UInt32] = [0, 1, 2] + let indexBuffer = unsafe indices.withUnsafeBytes { + unsafe device.createIndexBuffer(label: "First instance indices", format: .uInt32, bytes: $0.baseAddress!, length: $0.count) + } + let target = RenderTexture(size: [8, 2], scaleFactor: 1, format: .rgba8) + let command = device.createCommandQueue().makeCommandBuffer() + let pass = command.beginRenderPass(.init(colorAttachments: [ + .init(texture: target, operation: .init(loadAction: .clear, storeAction: .store), clearColor: .black) + ])) + pass.setViewport(Rect(x: 0, y: 0, width: 8, height: 2)) + pass.setRenderPipelineState(pipeline) + pass.setVertexBuffer(instances, offset: 0, slot: 0) + pass.setIndexBuffer(indexBuffer, offset: 0) + for instance in colors.indices.reversed() { + pass.setScissorRect(Rect(x: Float(instance * 2), y: 0, width: 2, height: 2)) + pass.drawIndexed(indexCount: 3, indexBufferOffset: 0, instanceCount: 1, firstInstance: instance) + } + pass.endRenderPass() + await withCheckedContinuation { (continuation: CheckedContinuation) in + command.addCompletedHandler { continuation.resume() } + command.commit() + } + let image = try #require(try await device.readImage(from: target)) + #expect(image.getPixel(x: 1, y: 1) == .red) + #expect(image.getPixel(x: 3, y: 1) == .green) + #expect(image.getPixel(x: 5, y: 1) == .blue) + #expect(image.getPixel(x: 7, y: 1) == .init(red: 1, green: 1, blue: 0, alpha: 1)) + } + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_SPRITE_WGPU_SMOKE"] == "1")) func transientRenderAttachmentsAreNotRetainedByBindings() async throws { let device = try makeDevice() diff --git a/Tests/AdaRenderTests/WGPUShadowCullProcessorTests.swift b/Tests/AdaRenderTests/WGPUShadowCullProcessorTests.swift new file mode 100644 index 000000000..ea765e1c5 --- /dev/null +++ b/Tests/AdaRenderTests/WGPUShadowCullProcessorTests.swift @@ -0,0 +1,109 @@ +#if canImport(WebGPU) && !os(WASI) && !os(Android) + @_spi(Internal) @testable import AdaRender + import Foundation + import Math + import Testing + + // Extends the Hi-Z suite so the WebGPU validation filter and serialization cover it. + extension WGPUVisibilityProcessorTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_WEBGPU_VISIBILITY_SMOKE"] == "1")) + func shadowCullKeepsEveryCasterTheCPUFrustumTestKeeps() async throws { + let device = try WebGPURenderBackend.createBackend().createLocalRenderDevice() + let processor = try #require(device.createGPUShadowCullProcessor()) + var random = SystemRandomNumberGenerator() + func value(_ range: ClosedRange) -> Float { Float.random(in: range, using: &random) } + + // Three parts; each caster's record holds a marker word identifying it. + let partCount = 3 + var casters: [GPUShadowCaster] = [] + var markers: [UInt32] = [] + for part in 0.. [UInt32] { + data.withUnsafeBytes { raw in + stride(from: 0, to: raw.count, by: 4).map { unsafe raw.loadUnaligned(fromByteOffset: $0, as: UInt32.self) } + } + } + + /// The CPU mesh-visibility frustum test (MeshVisibilityFrustum3D), restated here because AdaRender cannot see it. + private static func cpuVisible(_ caster: GPUShadowCaster, view: GPUShadowCullView, matrix: Transform3D?) -> Bool { + guard view.culls != 0, caster.minimum.w == 0, let matrix else { return true } + let bounds = AABB(min: caster.minimum.xyz, max: caster.maximum.xyz) + let rows = (0..<4).map { matrix.row(at: $0) } + let planes = [rows[3] + rows[0], rows[3] - rows[0], rows[3] + rows[1], rows[3] - rows[1], rows[2], rows[3] - rows[2]] + for plane in planes { + let radius = abs(plane.x) * bounds.halfExtents.x + abs(plane.y) * bounds.halfExtents.y + abs(plane.z) * bounds.halfExtents.z + if plane.xyz.dot(bounds.center) + plane.w + radius < -0.0001 { return false } + } + return true + } + } +#endif diff --git a/script/ada_tools/studio.py b/script/ada_tools/studio.py index 0d09eb833..881ce166b 100644 --- a/script/ada_tools/studio.py +++ b/script/ada_tools/studio.py @@ -16,10 +16,24 @@ def configuration(args): return "debug" if args.debug else "release" +def profiling(args): + return getattr(args, "profile", False) + + +def symbol_flags(host): + """Debug information for an optimized build, in the host profilers' native format.""" + flags = ["-Xswiftc", "-g"] + if host == "windows": + # WPA/Superluminal/Visual Studio read CodeView PDBs; lld-link writes them with -debug. + flags += ["-Xswiftc", "-debug-info-format=codeview", "-Xcc", "-gcodeview", "-Xlinker", "-debug"] + return flags + + def build(ctx, args): require_swift(ctx) mode = configuration(args) - scratch = (args.scratch_path or ctx.cache / "build" / args.target).resolve() + label = args.target + ("-profile" if profiling(args) else "") + scratch = (args.scratch_path or ctx.cache / "build" / label).resolve() if ctx.host == "windows": windows_environment(ctx) if args.target == "studio": @@ -31,7 +45,8 @@ def build(ctx, args): ctx.run(["xcodegen", "generate", "--spec", ctx.root / "Editor/project.yml"]) ctx.run(["xcodebuild", "-project", ctx.root / "Editor/AdaEditor.xcodeproj", "-scheme", "AdaEditor-Standalone", "-configuration", mode.capitalize(), "-destination", "platform=macOS", "-derivedDataPath", scratch, - "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "build"], log=ctx.cache / f"studio-{mode}-build.log") + *(["DEBUG_INFORMATION_FORMAT=dwarf-with-dsym"] if profiling(args) else []), + "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "build"], log=ctx.cache / f"{label}-{mode}-build.log") app = scratch / "Build/Products" / mode.capitalize() / "Ada Studio.app" ctx.run(["/usr/bin/codesign", "--force", "--deep", "--sign", "-", "--entitlements", ctx.root / "Editor/Platforms/AdaEditor-Standalone.entitlements", app]) @@ -39,6 +54,8 @@ def build(ctx, args): return app package = ctx.root / "Editor" if args.target == "studio" else ctx.root command = [ctx.swift, "build", "--package-path", package, "--scratch-path", scratch, "--configuration", mode] + if profiling(args): + command += symbol_flags(ctx.host) selection = [] if args.target == "engine": selection = ["--target", "AdaEngine"] @@ -46,11 +63,18 @@ def build(ctx, args): # Building every product also tries WASI-only command plugins on Windows. selection = ["--product", "AdaEditor"] # Linux packaging also needs gravity-lsp and AdaPackageTool, so build its products together. - ctx.run([*command, *selection, "--jobs", ctx.jobs], log=ctx.cache / f"{args.target}-{mode}-build.log") + ctx.run([*command, *selection, "--jobs", ctx.jobs], log=ctx.cache / f"{label}-{mode}-build.log") output = ctx.run([*command, "--show-bin-path"], capture=True) binary = Path(output.splitlines()[-1]) if output else scratch / mode if ctx.host == "windows" and args.target == "studio" and not ctx.dry: shutil.copy2(Path(ctx.env["LORE_WINDOWS_SDK_PATH"]) / "lore.dll", binary) + # Builds linked with the optional DLSS bridge load NVIDIA's NGX runtimes from the executable folder. + dlss = ctx.root / ".build-tools/dlss-interop" + if (dlss / "build.json").is_file(): + for name in ("nvngx_dlss.dll", "nvngx_dlssd.dll", "NVIDIA-DLSS-LICENSE.txt"): + if not (dlss / name).is_file(): + raise ToolError(f"DLSS runtime is missing: {name}; rerun script/build_dlss.py") + shutil.copy2(dlss / name, binary) return binary @@ -103,13 +127,19 @@ def validate(source, host): raise ToolError("Studio executable permission is missing: " + relative) -def default_destination(ctx, debug=False): - suffix = " Debug" if debug else "" +def variant(debug=False, profile=False): + """Installed variants live side by side: release, Debug and Profile (release + symbols).""" + return "Debug" if debug else "Profile" if profile else "" + + +def default_destination(ctx, debug=False, profile=False): + name = variant(debug, profile) + suffix = " " + name if name else "" if ctx.host == "macos": return Path.home() / "Applications" / ("Ada Studio" + suffix + ".app") if ctx.host == "windows": return Path(ctx.env.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs" / ("Ada Studio" + suffix) - return Path(ctx.env.get("XDG_DATA_HOME", Path.home() / ".local/share")) / ("ada-studio-debug" if debug else "ada-studio") + return Path(ctx.env.get("XDG_DATA_HOME", Path.home() / ".local/share")) / ("ada-studio-" + name.lower() if name else "ada-studio") def atomic_install(source, destination, host): @@ -158,8 +188,9 @@ def atomic_install(source, destination, host): return destination -def register(ctx, destination, debug=False): - name = "Ada Studio Debug" if debug else "Ada Studio" +def register(ctx, destination, debug=False, profile=False): + suffix = variant(debug, profile) + name = "Ada Studio " + suffix if suffix else "Ada Studio" if ctx.host == "windows": # Paths are passed via environment, never interpolated into PowerShell code. ctx.env["ADA_TOOL_INSTALL_DIRECTORY"] = str(destination) @@ -172,7 +203,7 @@ def register(ctx, destination, debug=False): elif ctx.host == "macos": ctx.run(["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", "-f", destination]) else: - desktop = Path(ctx.env.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "applications" / ("ada-studio-debug.desktop" if debug else "ada-studio.desktop") + desktop = Path(ctx.env.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "applications" / (("ada-studio-" + suffix.lower() if suffix else "ada-studio") + ".desktop") if not ctx.dry: # Desktop-entry quoting has different escaping rules from a shell. executable = str(destination / "ada-studio") @@ -204,7 +235,7 @@ def launch(ctx, destination): def install(ctx, args): - destination = (args.destination or default_destination(ctx, args.debug)).absolute() + destination = (args.destination or default_destination(ctx, args.debug, profiling(args))).absolute() if args.source: source = args.source.resolve() validate(source, ctx.host) @@ -248,7 +279,7 @@ def install_source(ctx, args, source, destination): atomic_install(source, destination, ctx.host) if not args.no_shortcut: try: - register(ctx, destination, args.debug) + register(ctx, destination, args.debug, profiling(args)) except (OSError, ToolError) as error: print("warning: Studio is installed, but application registration failed: " + str(error), file=sys.stderr) if args.launch: diff --git a/script/build_dlss.py b/script/build_dlss.py index e01d9ab92..afc3005ee 100644 --- a/script/build_dlss.py +++ b/script/build_dlss.py @@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] SDK_REVISION = "9981c6350dc58c0ce21f024fd65e31e851c31689" +RUNTIMES = ("nvngx_dlss.dll", "nvngx_dlssd.dll") def run(*args, **kwargs): @@ -41,7 +42,9 @@ def main(): sdk, dawn = paths() if args.stage_runtime: args.stage_runtime.mkdir(parents=True, exist_ok=True) - shutil.copy2(sdk / "lib/Windows_x86_64/rel/nvngx_dlss.dll", args.stage_runtime) + # Super Resolution and Ray Reconstruction (DLSS-D) runtimes. + for runtime in RUNTIMES: + shutil.copy2(sdk / "lib/Windows_x86_64/rel" / runtime, args.stage_runtime) shutil.copy2(sdk / "LICENSE.txt", args.stage_runtime / "NVIDIA-DLSS-LICENSE.txt") return output = args.output.resolve() @@ -64,7 +67,8 @@ def main(): dawnLibrarySHA256=hashlib.sha256((dawn / "webgpu_dawn.lib").read_bytes()).hexdigest(), includePaths=[str(path) for path in includes], libraries=[str(ngx), str(kernel)]) (output / "build.json").write_text(json.dumps(manifest, indent=2) + "\n") - shutil.copy2(sdk / "lib/Windows_x86_64/rel/nvngx_dlss.dll", output) + for runtime in RUNTIMES: + shutil.copy2(sdk / "lib/Windows_x86_64/rel" / runtime, output) shutil.copy2(sdk / "LICENSE.txt", output / "NVIDIA-DLSS-LICENSE.txt") if args.smoke: # Match the production executable's native dependencies, including bundled DXC. diff --git a/script/package_studio_windows.py b/script/package_studio_windows.py index 066f65407..6724d109c 100644 --- a/script/package_studio_windows.py +++ b/script/package_studio_windows.py @@ -136,6 +136,14 @@ def package(binary_directory, compiler, tint, output, version, smoke_test=True, if not source.is_file(): raise RuntimeError(f"Dawn runtime is missing: {name_dll}") shutil.copy2(source, directory) + # A build linked with the optional DLSS bridge (script/build_dlss.py) needs NVIDIA's signed NGX runtimes + # beside the executable: Super Resolution and Ray Reconstruction, plus their license. + dlss = ROOT / ".build-tools/dlss-interop" + if (dlss / "build.json").is_file(): + for name in ["nvngx_dlss.dll", "nvngx_dlssd.dll", "NVIDIA-DLSS-LICENSE.txt"]: + if not (dlss / name).is_file(): + raise RuntimeError(f"DLSS runtime is missing: {name}; rerun script/build_dlss.py") + shutil.copy2(dlss / name, directory) lore_sdk = Path(run("python", ROOT / "script/ensure_windows_lore.py")) search = [binary_directory, lore_sdk] + runtime + [Path(path) for path in os.environ.get("PATH", "").split(os.pathsep) if path] binaries = list(directory.glob("*.dll")) + [directory / "AdaEditor.exe", directory / "adastudio.exe", directory / "BuildSDK/AdaScript/gravity.exe", diff --git a/script/webgpu_validation_package.py b/script/webgpu_validation_package.py index 7ca75123c..cd28efe1a 100644 --- a/script/webgpu_validation_package.py +++ b/script/webgpu_validation_package.py @@ -61,6 +61,7 @@ "Tests/AdaRenderTests/WGPUBindingStateTests.swift", "Tests/AdaRenderTests/WGPUCommandCompletionTests.swift", "Tests/AdaRenderTests/WGPUVisibilityProcessorTests.swift", + "Tests/AdaRenderTests/WGPUShadowCullProcessorTests.swift", "Tests/AdaEngineTests/MaterialUploadReuseTests.swift", "Tests/AdaECSTests/OptionalResourceTests.swift", "Tests/AdaTransformTests/AdaTransformTests.swift", @@ -70,6 +71,7 @@ "Tests/AdaEngineTests/GoldenFrameSupport.swift", "Tests/AdaEngineTests/PluginLoadingTests.swift", "Tests/AdaEngineTests/WGPUVisibilitySceneTests.swift", + "Tests/AdaEngineTests/GPUShadowBatchingSceneTests.swift", "Tests/AdaEngineTests/Render3DDebugViewTests.swift", "Tests/AdaEngineTests/Render3DShadowFilteringTests.swift", "Tests/AdaEngineTests/Render3DLocalShadowPrecisionTests.swift",