diff --git a/.gitattributes b/.gitattributes index 9512df42e..7e8216206 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,7 @@ *.vert text eol=lf *.glsl text eol=lf *.sh text eol=lf +/Tools/Lore/*.patch text eol=lf /Sources/AdaEngine/AdaEngine.docc/*.png -diff -merge /Sources/AdaEngine/AdaEngine.docc/*.jpg -diff -merge diff --git a/.github/workflows/shader-graph-apple.yml b/.github/workflows/shader-graph-apple.yml index 0db0fcae3..81374e4e2 100644 --- a/.github/workflows/shader-graph-apple.yml +++ b/.github/workflows/shader-graph-apple.yml @@ -4,17 +4,25 @@ on: pull_request: branches: [main, develop] paths: + - 'Package.swift' + - 'Package.resolved' - 'Sources/**' - 'Editor/**' - 'Plugins/AdaShaderGraphBaker/**' + - 'Plugins/AdaWebExportPlugin/**' - 'Tests/**' - 'script/shader_graph*' + - 'script/web_golden_saga.mjs' - '.github/workflows/shader-graph-apple.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: shader-graph-apple-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: apple: name: ${{ matrix.check }} with Xcode 26.6 @@ -23,16 +31,19 @@ jobs: strategy: fail-fast: false matrix: - check: [cpu-baker, metal-native, ipad-simulator] + check: [cpu-baker, saga-default, metal-native, ipad-simulator, studio-web] env: DEVELOPER_DIR: /Applications/Xcode_26.6.app - ADAENGINE_PACKAGE_PATH: ${{ github.workspace }} - ADAENGINE_GRAVITY_PACKAGE_PATH: ${{ github.workspace }}/Sources/gravity-lang + ADAENGINE_PACKAGE_PATH: ${{ github.workspace }}/AdaEngine + ADAENGINE_GRAVITY_PACKAGE_PATH: ${{ github.workspace }}/AdaEngine/Sources/gravity-lang ADA_MCP_LOCAL_PATH: ${{ github.workspace }}/.build-tools/apple-mcp ADAENGINE_DISABLE_SWAN: '1' ADAENGINE_SKIP_SWIFTLINT: '1' steps: + # AdaMCP's local dependency uses the checkout directory as its SwiftPM identity. - uses: actions/checkout@v4 + with: + path: AdaEngine - name: Check out the pinned local MCP dependency uses: actions/checkout@v4 with: @@ -40,8 +51,23 @@ jobs: ref: ed7bb9076f9f8c91e2584ca9b7201df0e03defb1 path: .build-tools/apple-mcp persist-credentials: false + - name: Check out the pinned mobile runtime + if: matrix.check == 'ipad-simulator' + run: | + git init Sloppy + git -C Sloppy fetch --no-tags --no-recurse-submodules --depth=1 \ + https://github.com/TeamSloppy/Sloppy.git 0d8fd03d0269a480b6af3fb4f40333c319e2c398 + git -C Sloppy checkout --detach FETCH_HEAD - name: Build and run the production paths + working-directory: AdaEngine run: bash script/shader_graph_apple_validation.sh '${{ matrix.check }}' "$RUNNER_TEMP/shader-graph-evidence" + - name: Upload standalone Studio compiler log + if: always() && matrix.check == 'studio-web' + uses: actions/upload-artifact@v4 + with: + name: studio-web-compiler-log + path: AdaEngine/.build-tools/ada-tool/*/studio-debug-build.log + if-no-files-found: ignore - name: Upload logs and actual frames if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/studio-windows-release.yml b/.github/workflows/studio-windows-release.yml index 481c6ba20..0d5272509 100644 --- a/.github/workflows/studio-windows-release.yml +++ b/.github/workflows/studio-windows-release.yml @@ -42,6 +42,11 @@ jobs: with: # AdaMCP references the local package identity AdaEngine. path: AdaEngine + - uses: actions/checkout@v4 + with: + repository: SpectralDragon/SAGA + ref: ${{ vars.ADAENGINE_SAGA_REF }} + path: SAGA - uses: actions/setup-python@v5 with: python-version: '3.12' @@ -53,11 +58,6 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 with: arch: x64 - - name: Cache verified Tint - uses: actions/cache@v4 - with: - path: AdaEngine/.build-tools/tint - key: tint-windows-x64-${{ hashFiles('AdaEngine/Tools/Tint/toolchain.json', 'AdaEngine/script/ensure_tint.py') }} - name: Test portable dependency packaging run: | python script/test_build_studio_windows.py @@ -69,7 +69,6 @@ jobs: $studioBin = (& ./script/build_studio_windows.ps1 -Configuration release -Jobs 2 | Select-Object -Last 1).Trim() "bin=$studioBin" >> $env:GITHUB_OUTPUT "gravity=$env:ADAENGINE_GRAVITY_PACKAGE_PATH" >> $env:GITHUB_OUTPUT - "tint=$env:TINT_EXECUTABLE" >> $env:GITHUB_OUTPUT - name: Test installed SDK discovery run: | # Compile the actual production locators without testable app imports. @@ -81,11 +80,10 @@ jobs: env: STUDIO_BIN: ${{ steps.build.outputs.bin }} STUDIO_GRAVITY: ${{ steps.build.outputs.gravity }} - STUDIO_TINT: ${{ steps.build.outputs.tint }} RELEASE_TAG: ${{ github.event.release.tag_name }} run: | $studioVersion = if ($env:RELEASE_TAG) { $env:RELEASE_TAG } else { "ci-$($env:GITHUB_SHA.Substring(0, 12))" } - python script/package_studio_windows.py --bin "$env:STUDIO_BIN" --compiler-root "$env:STUDIO_GRAVITY" --tint "$env:STUDIO_TINT" --version "$studioVersion" + python script/package_studio_windows.py --bin "$env:STUDIO_BIN" --compiler-root "$env:STUDIO_GRAVITY" --version "$studioVersion" if ($LASTEXITCODE -ne 0) { throw 'Studio packaging failed.' } - name: Upload ZIP and SHA-256 uses: actions/upload-artifact@v4 diff --git a/Documentation/Designs/assets/saga-browser-parity/saga.jpg b/Documentation/Designs/assets/saga-browser-parity/saga.jpg new file mode 100644 index 000000000..6bc51ca93 Binary files /dev/null and b/Documentation/Designs/assets/saga-browser-parity/saga.jpg differ diff --git a/Documentation/Designs/assets/saga-browser-parity/swan.jpg b/Documentation/Designs/assets/saga-browser-parity/swan.jpg new file mode 100644 index 000000000..5328792fa Binary files /dev/null and b/Documentation/Designs/assets/saga-browser-parity/swan.jpg differ diff --git a/Documentation/Designs/saga-migration.md b/Documentation/Designs/saga-migration.md new file mode 100644 index 000000000..e6316c87b --- /dev/null +++ b/Documentation/Designs/saga-migration.md @@ -0,0 +1,160 @@ +# Migrate AdaEngine rendering and shader translation to SAGA + +Status: Proposed. + +## Scope + +Replace Swan/Dawn on AdaEngine's WebGPU rendering path with SAGA and replace Tint's SPIR-V-to-WGSL translation with SAGACompiler. Preserve existing shader inputs, materials, scenes, and the native Metal path. Keep existing GLSL compilation and reflection in this migration. + +## Local dependency + +Use a local SwiftPM dependency named SAGA. Resolve it from `ADAENGINE_SAGA_PACKAGE_PATH` when set, otherwise from the sibling `../SAGA` checkout for SAGA-enabled builds. Resolve the sibling relative to the AdaEngine manifest. Wire the compiler and platform-specific runtime products into their owning targets. Propagate the checkout path into staged builds and CI, with a clear missing-checkout diagnostic. Keep headless builds independent of GPU runtime dependencies. A remote package or distribution strategy is outside this migration. + +## Shader translation + +Replace the Tint subprocess in `WGSLShaderCompiler` with SAGACompiler. Preserve entry point names, stages, binding layouts, diagnostics, and shader cache correctness. Exercise actual AdaEngine GLSL/SPIR-V inputs and validate emitted WGSL in the browser. Migrate the export and shader preparation callers before removing Tint installation, bootstrap, plugin and CI wiring. + +## Rendering + +Adapt the existing AdaRender backend boundary to SAGA. Use SAGAWebGPU for browser canvas rendering. Evaluate the local SAGAD3D12 and SAGAVulkan implementations against AdaEngine's native platform requirements before selecting them for Windows and other affected native builds. The checkout contains native implementation code, while its README lists only Metal and browser WebGPU as verified runtime paths. Treat native availability, presentation and feature parity as validation requirements. + +Migrate shader translation first, then switch rendering paths as each passes its platform checks. Keep Swan/Dawn on paths awaiting parity during the transition. Before final removal, cover presentation and resize, render and compute passes, resource lifetime, uploads and readback, and existing Windows DLSS interop where enabled. Any supported behavior that requires a scope change returns for approval. + +## Acceptance and documentation + +- Local checkout resolution works from the root package, Studio, web export and CI, including an override path containing spaces. +- Production shaders compile with the intended entry points and bindings. Browser export runs through real WebGPU, and native and browser render checks pass against existing golden references within their existing tolerances. +- Existing Metal and headless builds pass. Every Dawn-backed configuration has a validated replacement before its dependency and packaging wiring are removed. +- Update the existing setup and build instructions in [Documentation/Tooling.md](../Tooling.md), including local checkout layout, override usage, supported platforms and verified Windows Studio and WASM commands. Check links and run those commands through their documented entry points. + +## Addendum: Backend slice 1 — SAGAWebGPU for browser/WASM (proposed, awaiting approval) + +Status: Proposed. No code is changed until this is approved. + +### Findings + +- Swan is used by about 6,000 lines in `Sources/AdaRender/Backends/WebGPU` (30 files) plus `WindowsApplication`, `LinuxApplication`, `RenderEngine` and `WGSLReflection`. +- AdaRender's backend is synchronous and object-oriented, written against Swan's WebGPU-style `GPUDevice`, `GPUBuffer`, `GPUTexture` and encoders. +- SAGA's `Device` is an `actor` with `async throws` creation calls returning `ResourceID` handles, and a `CommandList`/submission model. `SAGAWebGPU` currently contains only `BrowserBackend` and `BrowserComputeBackend`. +- The two models differ in threading, resource ownership and command recording, so this is an adapter layer, not a mechanical rename. + +### Proposed approach + +1. Add a `SAGARenderBackend` inside AdaRender (browser-only, `#if SAGA_WEBGPU`) implementing the existing `RenderBackend`/`RenderDevice` protocols, leaving the Swan backend untouched. +2. Bridge sync AdaRender calls to SAGA with a resource-handle table: creation returns an AdaRender object immediately and finishes in the background; use is ordered through the command list, so no render-thread blocking. +3. Port in this order, each verified in the browser: swapchain/canvas presentation → buffers, textures, samplers → render pipelines and render pass → compute and the processors (visibility, shadow cull, meshlet, FSR) → readback and timestamp queries. +4. Select the backend per build: browser/WASM uses SAGA; native keeps Swan until slices 2 (Windows/Vulkan/D3D12) and 3 (shaders) pass parity. + +### Validation + +- Browser export renders the existing golden scenes within current tolerances and compute passes match. +- Existing Metal, headless and native Swan builds still pass. +- Each ported area has Swift Testing coverage written Red → Green first. + +### Open decisions for the human + +- Is the async-handle adapter acceptable, or should SAGA gain a synchronous resource API first? +- Features SAGAWebGPU lacks must be listed before porting (e.g. timestamp queries, pipeline disk cache, DLSS). Should missing ones block, or be disabled in the browser with a diagnostic? + +### Addendum findings after starting slice 1 (blocker, needs decision) + +- `SAGAWebGPU` is only a thin `BrowserBackend` that forwards to a `BrowserGPUHost` protocol. The real host (`WebGPUHost`, a binary opcode/packet RPC to JavaScript, about 590 lines) and its JS bridge (`web/webgpu.mjs`, `reactor.mjs`) live in the `SAGAWebDiagnostic` executable, not in a library product. AdaEngine cannot depend on them as-is. +- SAGA submits declarative command lists (pipeline, bindings, attachments per pass) through async RPC. AdaRender uses stateful immediate encoders (`setPipeline`, `setVertexBuffer`, `draw`). The adapter must record state per draw and flush it as a SAGA command list. +- Prerequisite in the SAGA repository: promote the browser host and JS bridge to a library product (for example `SAGAWebHost`) and ship the JS files with it. This is a scope change that needs approval. + +### Implementation status: browser backend (slice 1) + +- SAGA: `SAGAWebHost` library (opcode host plus pluggable `BrowserTransport`), `makeWebDeviceCapabilities`, and `host.attachCanvas` / `openWebGPU(null, { deferCanvas: true })` in `web/webgpu.mjs`. +- AdaRender: `Backends/SAGA` implements `RenderBackend`/`RenderDevice` for browser/WASM behind `SAGA_WEBGPU`. A page that defines `globalThis.__adaSAGAHost` renders through SAGA; otherwise Swan is used. The web export opens the host when the URL has `?renderer=saga` and ships `saga-webgpu.mjs` from the SAGA checkout. +- Sync AdaRender calls reach SAGA's async `Device` through `OrderedGPUWorkQueue`; a window is an offscreen texture copied to the canvas on present; each draw becomes one SAGA render command, and clear-only passes draw a degenerate triangle. +- Not yet supported on this backend: indirect/multi-draw, FSR/DLSS upscalers, GPU visibility/shadow/meshlet processors, timestamp queries, cube/array texture views, stencil-only attachments. The device reports these as unsupported. +- Remaining before Swan can be removed: browser golden-frame parity, the native Windows/Metal/Android backends, the shader migration, Shader Graph, and removal of Tint and Dawn wiring. + +### Browser verification log + +- 2026-11-12: `AdaWebPlayer` exported through `export-web` and packed with a small 3D project (camera with `Environment3D`, sun, three lit meshes). Loaded in Chromium at `?renderer=saga&solo=1`, the SAGA backend presented frames (`host.metrics().presentedFrames` 144 at 1369×829) and showed the sky and the three meshes. This is a smoke check, not golden-frame parity. +- Observed gaps: the 30×30 floor plane is not visible, and surfaces look darker than expected; both need investigation against the Swan path (the Swan path rendered only a blank dark canvas in the same browser session, so it could not serve as the reference there). +- Web Player fix made on the way: it now accepts `AdaCorePipelines.Environment3D`, `AdaScene.ScriptableComponents` and `AdaUI.CompanionPanel`. +- With `?sagadebug` (waits for each submission and logs `takeUncapturedErrors`), the smoke scene produced no SAGA device errors. The missing floor and dark shading are therefore not explained by a SAGA validation failure; they may come from the test scene (sun orientation, plane facing) and need a reference render to settle. The Swan browser path traps in `WGPUShadowCullProcessor.encode` in this session, so it cannot serve as that reference without a Swan-side fix. + +### Browser parity check (smoke scene) + +Scene: [Tests/WebGolden/SAGASmoke](../../Tests/WebGolden/SAGASmoke/README.md) (camera with `Environment3D`, sun with shadows, a floor, two cubes and a sphere). +Reference captures from the same bundle and browser: [Swan](assets/saga-browser-parity/swan.jpg) and [SAGA](assets/saga-browser-parity/saga.jpg). + +Findings fixed while making SAGA match Swan: + +1. **Front-face winding.** An unset `frontFaceWinding` means clockwise on the other backends; SAGA pipelines defaulted to counter-clockwise, which culled the visible faces (dark shading, missing floor). +2. **Attachment stores.** SAGA runs each draw as its own render pass, so a pass that declares "don't store" left later draws of the same AdaRender pass with undefined depth. Draws now always store attachments (only one of three shadows appeared before the fix). +3. **Swan in the browser.** `WGPUShadowCullProcessor` traps in WASM; the Swan device now reports no GPU shadow cull there so it can serve as the reference. + +Comparison is visual: floor, cube/sphere shading and all three cast shadows match. It is not an automated golden-frame test: WebGPU canvases read back blank from the page, so a pixel capture needs an engine-side readback hook in the host (`present`) or a headless browser harness. That remains to do before Swan can be removed from the browser path. + +Debug aids (opt in with `?sagadebug`): waits for every submission, prints SAGA device errors, and prints once per pipeline the declared versus bound resources and vertex layout. + +### Golden-frame test (browser) + +- SAGA's JS host exposes `host.captureNextFrame()` (reads the presented texture back to RGBA) and `web/golden.mjs` provides `captureStable`, `checkGolden`, `compareFrames` and PNG helpers. The export ships them as `saga-webgpu.mjs` and `saga-golden.mjs`. +- Reference: `Tests/AdaEngineTests/GoldenFrames/saga-browser/smoke-160x96.png`, recorded from the frame that visually matched Swan. A fresh page load reproduces it exactly (mean error 0); a frame darkened to half brightness fails (mean error about 76), so the check detects regressions. +- Runner: `node script/web_golden_saga.mjs [--record]` (needs `playwright-core` and a WebGPU Chromium). It has not been executed end to end in this environment because no browser automation package is installed; the same helpers were run in the in-app browser instead. +- Open: the reference is a SAGA self-golden validated by eye against Swan on this one scene; Swan frames cannot be captured the same way (its canvas reads back blank), so there is no automated cross-backend diff yet. + +### Status: browser builds no longer link Swan + +- `AdaWebPlayer` builds for wasm without Swan's `WebGPU` product (Package.swift limits it to Apple, Linux, Windows and Android). The built wasm contains no `WebGPUWasm` references. The WASM shader-loading paths key on `WEBGPU_ENABLED` instead of `canImport(WebGPU)`. +- The web export always opens SAGA's host and fails with a clear message when no SAGA checkout is found. `?renderer=swan` no longer exists. +- Golden check on the default URL passes against `smoke-160x96.png` (mean error 0) with the Swan-free build. +- Still on Swan/Dawn: native Windows, Linux and Android WebGPU. Still GLSL: the ten shaders listed in the shader migration plan. + +### Status: native Vulkan adapter (offscreen) + +- The SAGA adapter is shared by the browser and native builds (`SAGA_RENDER`). The device factory and shader format are injected: WGSL for the browser, SPIR-V for Vulkan. `SAGAShader` accepts WGSL source and SPIR-V. +- `ADAENGINE_SAGA_NATIVE=1` builds AdaRender with SAGA's Vulkan runtime on Windows and Linux. It does not need Swan, so it also builds with `ADAENGINE_HEADLESS=1`, which avoids the Dawn artifact download. +- `SAGAVulkanDeviceTests` (3 tests) pass on the Windows Vulkan device: buffer round trip, offset write, and a render pass that draws a fullscreen triangle into an `rgba8` texture and reads back the expected pixel colors. The run reported no SAGA device errors. +- Not done on native: a `RenderBackend` that creates windows. SAGA's Vulkan device must be created together with the window's surface, while the engine creates GPU resources before any window exists. The queued device factory can wait for the first surface, but capabilities are read synchronously, so a surface-less probe device or a deferred-capabilities design is needed first. Also open: engine GLSL/SPIR-V reflection and bindings on native, multiple render targets on D3D12, and Linux/Android/Metal. + +## Native Vulkan renders the real 3D pipeline (status) + +`Render3DGPUTests/SagaNative` renders the debug-view scene (four PBR spheres, sun with cascaded shadows, floor, SSAO, +screen-space reflection composite) on SAGA's Vulkan runtime through `flat3d.wesl` and the other WESL shaders: + +```bash +ADAENGINE_GOLDEN_SAGA=1 ADAENGINE_SAGA_NATIVE=1 ADAENGINE_SAGA_WINDOWLESS=1 swift test --filter Render3DGPUTests/SagaNative +``` + +Getting there needed these changes: + +- **SAGA Vulkan multiple render targets.** Pipelines, render passes, the planner and the recorder accept color targets at + locations 1 to 7 (flat3d writes four). A location without a target is an unused attachment that writes nothing. + Multisampled and resolving passes with extra targets are still rejected. `SAGAVulkanDeviceTests.aPassWritesEveryColorTarget` + covers it on a real device. +- **SAGA Vulkan `float32-filterable` and `float32-blendable`** are reported from the adapter's format properties, so + rgba32f depth/position buffers can be sampled with a filtering sampler. +- **SAGA sampled-texture capacity 16 -> 24** per stage. flat3d with lightmap and irradiance grid binds 17 textures. +- **One SAGA device.** `createLocalRenderDevice()` returns a device on the primary context. A resource id is only valid on + the device that created it, and textures loaded through the "local" device are bound by the renderer's passes. +- **Windowless devices.** The primary device normally waits for the first window's surface. Offscreen tools and tests set + `ADAENGINE_SAGA_WINDOWLESS=1` (or pass `windowless: true`) to create it without one. +- **Attachment lifetime.** A render pass keeps its attachment textures alive until its commands were built, because a + texture destroys its GPU resource when it is released. + +Diagnostics: `ADAENGINE_SAGA_DEBUG=1` prints device errors and each pipeline's declared and unbound resources; +`ADAENGINE_SAGA_TRACE=1` prints every queued job's origin as it begins, ends or fails. + +Not verified yet: pixel parity with the Metal and WebGPU references (no reference exists for this backend, and +`ADAENGINE_GOLDEN_RECORD=1` would pin this GPU's output), other scenes, and presentation of the 3D scene to a window. + +### Shader Graph on the WESL templates (status) + +The native renderer now compiles a Shader Graph through `ShaderGraph.compileWGSL()` and splices it into the surface +templates at their `// ADA_GRAPH_SOURCE_MARKER` line: `flat3d.wesl`, `directional_shadow_3d.wesl` (shadow and contact +depth alpha masks) and `motion_3d.wesl`. `ShaderModule.createWESL(source:packageRoot:features:)` links the generated +source in memory (`WESLHostCompiler.compile(source:...)`), so templates can import their package siblings. The graph's +local result variable is `ada_graph_result` because a template may import a binding called `surface`. + +`Render3DGPUTests/SagaNative/aShaderGraphMaterialRendersThroughTheWESLTemplate` renders a graph-colored floor on Vulkan. + +Still GLSL: sprite graphs (`SpriteGraphPipeline`), the offline baker (`AdaShaderGraphBaker`) and the web export, which +prelinks flat3d, mesh2d and canvas variants only as GLSL until the exporter links WESL. `ShaderGraphArchive` stays at +version 1: its fragments are keyed by GLSL source, so the version bump and the rejection of GLSL-era archives belong with +the baker port. diff --git a/Documentation/Designs/saga-renderer-cutover.md b/Documentation/Designs/saga-renderer-cutover.md new file mode 100644 index 000000000..83780a419 --- /dev/null +++ b/Documentation/Designs/saga-renderer-cutover.md @@ -0,0 +1,44 @@ +# SAGA renderer and WESL shader cutover + +Status: Proposed. + +This proposal supersedes the end state in [the initial SAGA migration design](saga-migration.md) and builds on the [built-in shader migration](wesl-builtin-shader-migration.md). The existing work in both checkouts is the starting point. + +## Starting point and cutover order + +- AdaEngine `514794db8` already has a shared SAGA adapter and a browser export that opens the SAGA host by default. The Vulkan backend has native window adapters, but the default on Windows, Linux, and Android still uses Swan/Dawn unless built with `ADAENGINE_SAGA_NATIVE=1`. Apple still uses AdaEngine's existing Metal backend. The browser smoke golden covers one scene; it does not establish parity across engines or platforms. +- The local SAGA checkout at `cc2aacc` does not export `SAGAWebHost` or provide the `makePresentableDevice`, `addSurface`, and `removeSurface` Vulkan APIs required by this AdaEngine checkout. Identify and pin a compatible SAGA revision, or land and validate the missing API there, before switching native defaults. Verify the product graph and browser host assets from a clean checkout in CI. +- Enable defaults platform by platform once the production build and presentation checks pass. Use Windows Vulkan and the existing browser smoke as the first verification targets; exercise Linux and Android on their own hosts and coordinate Apple separately. Keep the old native path available during verification. Remove it after the affected platforms pass their cutover checks. + +## Runtime and platform ownership + +AdaRender retains its public rendering interfaces. SAGA owns GPU resources, shader compilation for its backends, submission and presentation. AdaRender adapts its encoders to SAGA command lists and preserves resource lifetimes across queued work, including resize, surface replacement, device shutdown and readback. Headless configurations continue to work without a graphics device. + +| Platform | Default rendering path | Validation owner | +| --- | --- | --- | +| Windows | SAGA Vulkan with native window presentation | Codex | +| Linux | SAGA Vulkan with native window presentation | Codex | +| Android | SAGA Vulkan with Android surface lifecycle integration | Codex | +| Web | SAGA WebGPU with the packaged browser host | Codex | +| Apple | SAGA Metal | Contributor on Apple hardware | + +Vulkan on Windows reuses the existing adapter and lets Windows, Linux and Android share native rendering code. Windows-specific GPU integrations, including enabled DLSS configurations, need verified SAGA support or an explicit scope decision before retiring their current path. Changes to SAGA can supply missing capabilities. Keep Apple-specific runtime work with the contributor while coordinating changes to shared interfaces and manifests. + +Engine apps, Studio, standalone players and exported games select SAGA through their normal entry points without a renderer flag. Package the matching SAGA dependencies and web host assets with each distribution. Keep the sibling checkout and `ADAENGINE_SAGA_PACKAGE_PATH` for local development. Release and CI builds resolve a reproducible SAGA revision, including when built outside the development checkout. + +## Shaders and project compatibility + +Use WESL for modular shader sources and variants, and WGSL for standalone sources and generated entry points. Migrate engine and Studio shaders, material templates, Shader Graph generation and baking, and build-time export variants. Generate reflection from the WESL/WGSL pipeline and preserve resource layouts, entry points and diagnostics. SAGA can compile target artifacts such as SPIR-V for Vulkan. + +Existing scenes, built-in materials and editable Shader Graphs retain their behavior. Rebuild older baked Shader Graph artifacts from source and diagnose stale artifacts when the source is unavailable. Projects with handwritten GLSL receive an actionable migration diagnostic and a guide for porting to WESL/WGSL. The completed cutover removes GLSL, Swan/Dawn and Tint from rendering and export dependencies, tooling and distribution payloads. + +## Acceptance + +- Build and launch the engine, Studio and applicable players through documented default entry points. Build each supported export through its production command and run the result, including browser and Android exports. Check that build manifests and shipped assets resolve to SAGA. +- Run GPU pixel readback tests against established references with existing tolerances where scenes are comparable. Cover 2D sprites, text and UI, 3D materials and Shader Graphs, shadows, post-processing and compute-driven passes. Extend the browser smoke golden with representative coverage and compare new backend captures against known-good output before adopting references. Review any intended pixel differences with the contributor. +- Capture presented frames from Studio and exported players on the platforms where they ship. Exercise resize and surface recreation, and exercise Android pause/resume. Offscreen pixel tests complement these presentation checks. Record the command, platform, GPU/backend, reference, threshold, and diff or capture path for each run. +- Run contribution validation and independent review. Apple sign-off includes contributor-provided builds, presented frames and golden results. Any target without runtime evidence remains open. + +## User documentation + +Update the discoverable [WESL shaders article](../../Sources/AdaEngine/AdaEngine.docc/Articles/Developers/WESLShaders.md), linked from the [AdaEngine DocC catalog](../../Sources/AdaEngine/AdaEngine.docc/AdaEngine.md), and the [tooling and export guide](../Tooling.md). Include a GLSL-to-WESL material example, Shader Graph rebaking instructions, and platform setup and export commands. Compile the shader example, run the documented export commands, and check DocC links and navigation before review. diff --git a/Documentation/Designs/wesl-builtin-shader-migration.md b/Documentation/Designs/wesl-builtin-shader-migration.md new file mode 100644 index 000000000..49cce2be1 --- /dev/null +++ b/Documentation/Designs/wesl-builtin-shader-migration.md @@ -0,0 +1,85 @@ +# Replace the GLSL pipeline with SAGA and WESL/WGSL + +Status: Accepted — implementation in progress. + +Approval: explicit approval of this revised document in the project conversation on 2026-11-11; source reference recorded in the local contributor task state. + +Scope decisions confirmed in the project conversation on 2026-11-11: use SAGA and WESL/WGSL throughout the first-party shader pipeline, and remove support for user-authored GLSL as well as built-in GLSL. These decisions supersede the earlier compatibility-preserving proposal. Approval of this revised document is recorded separately before implementation. + +## Scope + +Make WESL the maintained source of all built-in engine and Studio shaders. The checked-out inventory on 2026-11-11 contains 55 first-party tracked GLSL files under `Sources` and `Editor`, including shared headers and an editor sample. Of these, 31 have a same-basename WESL source and 24 do not. These are source-file counts, not executable-pass counts or proof of backend parity. Shared WGSL modules already cover some differently named GLSL headers. The inventory also includes embedded shader source and the production Shader Graph generator. The final public shader-loading contract accepts WESL/WGSL, not GLSL. Legacy inputs remain only as temporary migration references until cutover. + +Third-party shader sources in `Sources/box3d`, `Sources/glslang`, and `Sources/harfbuzz` are outside the built-in migration. Do not rewrite vendored dependencies merely to reach a repository-wide zero-GLSL count. + +## Current state and remaining ownership + +- **Core pipelines:** the same-basename ports are present for most post-processing, 2D primitives, directional shadows, motion, and meshlet mapping. Remaining entry sources without peers are `flat3d`, `glass`, `local_volumetric_fog_3d`, `volumetric_fog_3d`, `particles_3d`, `path_tracing_3d`, and `screen_space_gi_3d`. The PBR port must preserve all actual material and geometry variants, not just the default mesh. +- **Render materials and mesh2D:** migrate the two canvas material sources, `mesh2d` and its three include modules. Reuse the existing `view.wgsl`, `mesh_morph.wgsl`, `color_grading.wgsl`, `tonemapping.wgsl`, and `mesh_visibility.wgsl` where applicable. Their presence is not evidence that every GLSL consumer has been migrated. Audit remaining baked diffuse, distance-field, and shadow-PCF helpers and all transitive consumers before retiring public GLSL headers. +- **UI and Studio:** port `ui_shadow`, `agent_activity`, `mobile_voice_orb`, and the `AgentActivityGlow` example. Preserve their existing appearance and parameters; this is not a UI redesign. +- **Generated shaders:** migrate `ShaderGraphProgram`, `ShaderGraphPipeline`, and `AdaShaderGraphBaker` together with the templates they extend. The current generator emits GLSL and the pipeline injects it through a GLSL marker. The baker still explicitly uses GLSL sprite/PBR/shadow/motion templates. Version archive and artifact identities when generated source format changes. Preserve graph topology and material parameters that can be regenerated through the new generator; explicitly reject legacy source-only GLSL archives rather than compiling them through a hidden fallback. + +### Runtime and export constraints + +`ShaderModule.supportsBundledWESL` currently selects WESL only for supported WebGPU builds. Native `createWESL` requires a WGSL device and excludes Android; Metal still consumes MSL and Android currently consumes SPIR-V on its WebGPU path. Consequently, same-basename GLSL files remain live fallbacks, not automatically redundant files. + +The web exporter currently discovers both WESL and staged GLSL sources. Make source selection deterministic for migrated passes without dropping required fallback/backend artifacts. Move the hard-coded PBR variant inventory from its GLSL-only assumptions when porting `flat3d`; retain existing morph and meshlet variants and artifact naming/reflection compatibility. Browser startup continues to consume prelinked WGSL rather than SPIR-V or WESL source. + +Producing MSL/SPIR-V from the maintained WESL source and integrating those artifacts is a required work item, not an assumed existing compiler capability. Establish available compiler output and validate representative resource layouts before removing any backend fallback. If a required output is unsupported, implement the missing SAGA capability as an explicit prerequisite or report the cutover as blocked. Temporary GLSL paths may exist during development but cannot remain in the delivered production pipeline. Do not silently switch runtime backends or declare a batch fully migrated without backend evidence. + +## Migration sequence + +1. Port the remaining UI passes, then the sprite, Spine, text, tilemap, and mesh2D passes. Share common transforms and data types through WESL imports. Preserve vertex locations, uniform offsets, resource bindings, color formats, and blending. +2. Port the 3D and compute passes. Carry over the actual material, skinning, terrain, wireframe, motion, and lighting variants. Use WESL conditions for structural changes and WGSL overrides for pipeline constants. Export every variant required by the browser. Use small parity batches and keep the production loader as the test entry point. +3. Port Shader Graph generation and baking to the same WESL/WGSL path. Preserve material identities and parameter/texture bindings; regenerate graph-backed archives and explicitly diagnose unsupported legacy source-only GLSL archives. Exercise generated variants together with the built-in templates they extend. +4. Deliver backend artifacts from the same WESL source. Native and browser WebGPU consume WGSL, Metal consumes MSL, and the current Android path consumes SPIR-V. Validate each output with its production backend before retiring that backend's duplicate built-in GLSL. Keep verified fallback paths during the transition. + +## Shared requirements + +Extend the in-process Swift linker and asset loaders where the migrated shaders require additional syntax, package resolution, and compute or variant metadata. Derive reflection and entry points from validated shader IR. Cache artifacts by entry point, backend, enabled features, toolchain revision, and the contents of all resolved source modules. The browser uses prelinked artifacts and their matching metadata. It does not link source files at startup. + +## Breaking change and cutover + +The user explicitly selected removal of user-authored GLSL support. WESL/WGSL is the maintained input format; SAGA supplies validation, compilation and authoritative IR-derived reflection. Replace GLSL-dependent reflection paths as part of cutover. Metal and other backend artifacts must originate from these inputs through SAGA, not a retained GLSL/SPIRV-Cross/Tint compilation route. Changing the GPU runtime backend is not implied by this shader migration. + +- Reject `.glsl` inputs and legacy GLSL-backed materials with an actionable diagnostic naming the asset and the WESL/WGSL migration guide. Do not silently reinterpret source, render a substitute material, or automatically overwrite project assets. +- Regenerate Shader Graph artifacts from retained graph data. Source-only GLSL archives require manual porting and rebaking; provide a version-specific error rather than promising lossless automatic conversion. +- Remove first-party GLSL entrypoints, embedded source generators, packaged shader resources, export/baker recipes and production GLSL compiler dependencies after all supported backend replacements pass. Preserve negative tests proving legacy inputs fail clearly. Remove obsolete GLSL success fixtures or replace them with WESL/WGSL equivalents without dropping behavioral coverage. +- Remove direct first-party glslang, SPIRV-Cross and Tint pipeline dependencies when their callers have been replaced. Inspect remaining callers before removing targets or vendored directories: third-party-owned source is outside scope, but must not serve as a concealed first-party fallback. +- Review existing accepted shader-source/SAGA designs and ADRs for compatibility promises; record a superseding decision at implementation time instead of rewriting historical decisions. +- Final acceptance requires no production first-party GLSL compilation or fallback on any supported platform. A missing SAGA backend or unavailable required device check blocks full completion; retaining the old path is not the final solution. + +## Acceptance and documentation + +- Inventory production source and generator entry points before and after the migration. Every built-in shader has a WESL source and a tested artifact for each backend that uses it. +- Verify GPU output against existing render references and compute readbacks. Cover uniform layouts, variant selection, Shader Graph materials, and a real browser export with a representative 2D and 3D scene. +- Build and exercise native WebGPU, browser WebGPU, Metal, and Android. Track unavailable hardware or unsupported SAGA output separately from passing checks. +- Add regression tests for rejected GLSL assets and legacy source-only archives, graph-backed archive regeneration, SAGA reflection, and absence of production fallback/dependency wiring. +- Update the existing [WESL DocC article](../../Sources/AdaEngine/AdaEngine.docc/Articles/Developers/WESLShaders.md) with built-in shader organization, variants, backend prerequisites, and a tested GLSL-to-WESL porting example. Explain the breaking change, manual source-only archive migration and graph rebaking; link it from `Sources/AdaEngine/AdaEngine.docc/AdaEngine.md`. Keep its DocC navigation and verify the docs build and example. + +## Implementation status (2026-11-12) + +Done and tested: + +- **Studio and UI passes:** `agent_activity.wesl` and `mobile_voice_orb.wesl` are WESL ports of the GLSL passes, and `ui_shadow.wesl` is now loaded by its material through `ReflectedMaterial.shaderModule()`. `BuiltInWESLShaderTests` links all three with the host compiler and checks entry points, the material uniform size (48, 48 and 32 bytes) and Metal stage output. The GLSL stage sources remain as the transitional fallback for backends without WESL support. +- **Native SAGA backend:** `RenderBackendType.saga` consumes WESL modules (`consumesWGSL`) and compiles GLSL-derived SPIR-V directly. `ADAENGINE_SAGA_NATIVE=1` links `AdaWGSLCompiler` so `ShaderModule.supportsBundledWESL` is true there. +- **Shader Graph generator:** `ShaderGraph.compileWGSL()` produces WGSL from the same lowered IR and shared analysis as `compileGLSL()` (identical parameter slots, texture bindings and source map). `ShaderGraphWGSLTests` links every math node across scalar and vector types, the noise graph and a textured graph, and checks bindings and uniform block size against the GLSL program. The existing GLSL graph tests still pass. + +Blocked, in order: + +1. **`flat3d`, `glass`, `particles_3d`, `path_tracing_3d`:** the PBR port must carry every material, skinning, terrain, wireframe, morph and meshlet variant and needs GPU reference comparison before the GLSL source can go. Not started. +2. **`mesh2d` family and the two canvas materials:** `ReflectedMaterial.shaderModule()` does not support macro variants, which `mesh2d` selects with `VERTEX_*` defines. The hook needs conditional-feature variants first. +3. **Shader Graph consumers:** `ShaderGraphPipeline`, the baker and the archive still embed GLSL in GLSL templates. The archive version must not change until the baker generates WGSL from the WESL templates in step 1, otherwise existing GLSL-baked archives would be mislabelled. At that point version 1 archives are rejected with a version-specific rebake error. +4. **GLSL removal:** loaders, packaged GLSL resources and compiler dependencies go last, after backend evidence for each platform. + +## Shared helper modules (ported) + +`Tonemapping`, `ColorGrading`, `ShadowPCF`, `Morph`, `DistanceField3D` and `BakedDiffuse3D` now exist as WESL modules in +`Sources/AdaRender/Assets/Shaders/WESL` (`tonemapping`, `color_grading`, `shadow_pcf`, `morph`, `distance_field`, +`baked_diffuse`), imported as `ada_engine::`. Textures and samplers that the GLSL headers expected as includer +globals are function parameters. `Morph`, `DistanceField3D` and `BakedDiffuse3D` keep their fixed bindings and are gated by +the `ADA_MORPH`, `ADA_LIGHTMAP` and `ADA_IRRADIANCE_GRID` features. `WESLLibraryModuleTests` links each through an entry +shader and compiles it to MSL. + +Known linker issue: a library function parameter that has the same name as a global declared in the importing entry file +(for example `lut`) fails with "Name ... is not imported". The tests use distinct global names; fix in SAGA's WESL linker. diff --git a/Documentation/Designs/wesl-shader-sources.md b/Documentation/Designs/wesl-shader-sources.md new file mode 100644 index 000000000..2ba72e419 --- /dev/null +++ b/Documentation/Designs/wesl-shader-sources.md @@ -0,0 +1,26 @@ +# WESL shader sources in AdaEngine + +Status: Accepted. Implementation is in progress. + +## Goal + +Use WESL as the source format for new modular WGSL shaders. Support shared shader code and compile-time variants while preserving the existing shader paths during incremental migration. + +## First milestone + +Follow the upstream [Imports](https://wesl-lang.dev/spec/Imports), [Visibility](https://wesl-lang.dev/spec/Visibility), and [Conditional Translation](https://wesl-lang.dev/spec/ConditionalTranslation) semantics for the supported syntax. Pin the upstream specification revision in the implementation and record its supported subset. This milestone covers local shader packages, explicit item and module imports, grouped imports, aliases, and qualified references. Resolve `package::` and `super::` paths within configured package roots, preferring `.wesl` over `.wgsl`. Defer wildcard imports, public re-exports, registry integration, and package publishing. Report unsupported syntax with source locations. + +Apply `@if`, `@elif`, and `@else` using boolean build features before resolving imports. Cover conditional imports, top-level declarations, structure members, parameters, and statements. Keep WGSL `override` available for pipeline constants. Feature flags that alter the shader interface produce distinct artifacts with matching reflection. + +## Integration + +Use one in-process Swift WESL linker for native WebGPU and web export. AdaEngine supplies the package roots, entry points, and variant features. The linker resolves declarations and produces ordinary WGSL with original source locations for diagnostics. The JavaScript linker was evaluated, and the contributor selected an implementation in the engine. A textual include expander cannot provide the required name resolution and visibility. + +Validate the linked WGSL and derive the entry point and resource metadata from the WGSL path using SAGA. Use the same artifacts for browser export, with linking and validation performed on the host. Include the active transitive module sources, feature flags, entry points, and toolchain version in the artifact cache key. Keep the existing GLSL and SPIR-V paths available to their current callers during migration. + +## Acceptance and documentation + +- Exercise the normal native and export entry points with a shader that imports a reusable helper and has two variants with different binding layouts. Use a small 2D pass before migrating the PBR suite. +- Verify aliases, relative and qualified paths, duplicate and missing symbols, visibility, inactive imports, and diagnostics referring to the imported source. Check that edits to a transitive module invalidate the affected artifact and that variants cannot share incompatible reflection. +- Verify the resulting shader and bindings on native WebGPU and in a real browser export. Compare rendering with the existing reference for the migrated pass. +- Add a WESL article to the AdaEngine DocC catalog and link it from its Topics. Include a tested two-file shader, variant configuration, supported syntax and diagnostics. Verify the documentation build and run the documented example through the production paths. diff --git a/Documentation/Plans/saga-renderer-cutover.md b/Documentation/Plans/saga-renderer-cutover.md new file mode 100644 index 000000000..fc4985444 --- /dev/null +++ b/Documentation/Plans/saga-renderer-cutover.md @@ -0,0 +1,166 @@ +# SAGA renderer cutover delivery sequence + +This implements the [approved cutover design](../Designs/saga-renderer-cutover.md). Keep the existing runtime selection until each replacement is demonstrated. The budgets below are estimates for one reviewable change; split larger implementations by behavior. Review the combined result independently after all tasks, as requested. + +Current delivery scope: finish SAGA Metal and browser WebGPU. Windows, Linux, and Android Vulkan +remain follow-up work. Do not change their default renderer or remove their build dependencies +as part of this delivery. + +| Change | Depends on | Integration and evidence | Approximate budget | +| --- | --- | --- | --- | +| Publish a reusable SAGA browser host | SAGA baseline | Add a library and injectable transport; run the existing browser diagnostic plus deferred canvas tests. | 600 lines, excluding source moves | +| Complete SAGA Vulkan presentation contract | SAGA baseline | Add surface attachment, resize and retirement to the shared device; test Win32 and Xlib presentation, then Android separately. | 600 lines per platform slice | +| Pin matching SAGA revision in AdaEngine | Browser and Vulkan contracts | Exercise dependency resolution in a clean checkout; use the existing native opt-in flag until verified. | 300 lines | +| Verify browser WebGPU | Matching revision | Run a clean browser export, the smoke golden, and a presented-frame readback with the matching host assets. | 600 lines | +| Verify and enable Windows Vulkan (follow-up) | Matching revision | Run Studio and native readback with Vulkan. Keep Windows DLSS support subject to the approved design. | 600 lines | +| Verify and enable Linux and Android Vulkan | Matching revision | Run native windows and Android export with pause/resume and surface replacement before changing defaults. | 600 lines per platform slice | +| Integrate Apple SAGA Metal | Matching revision | Verify offscreen 3D, real window presentation and resizing, and shader output on Apple hardware before changing defaults. | 600 lines per behavior slice | +| Complete WESL and Shader Graph cutover | Existing shader migration and verified runtime paths | Switch source variants and bake/archive paths after artifact, readback and export checks. Preserve graph parameters and reject legacy source-only GLSL with a migration diagnostic. | 600 lines per shader family | +| Remove legacy render and compiler wiring | All default paths and shader checks | Remove the temporary flag and Swan/Dawn, GLSL and Tint dependencies where unused. Update DocC and tooling guides and verify documented examples and exports. | 600 lines per removal slice | + +## Companion revision and validation + +The local SAGA candidate is `8bbf2cf7bcabf561df4db1eda8656457f6ef069f` on +`codex/adaengine-compatibility`, based on `cc2aacc61116449950c46de45e0b3bf3a26aa77b`. It adds +`SAGAWebHost` with an injected transport, deferred canvas attachment and frame readback, and Vulkan +surface attachment and retirement. The local checkout is `.build-tools/saga-companion`; the Git +bundle at `.build-tools/saga-adaengine-compatibility.bundle` imports the two commits into a SAGA +checkout that has the base commit. Use `ADAENGINE_SAGA_PACKAGE_PATH` to build against the candidate. + +- Built the browser diagnostic target and AdaRender for WASM with Swift 6.3.2. Built SAGAVulkan + locally and ran three SAGA presentation contract tests on macOS. +- Chromium with hardware WebGPU presented a BGRA texture through the deferred canvas and returned + the expected RGBA bytes. The AdaEngine `export-web` command built AdaWebPlayer; the packaged + `SAGASmoke` scene passed the existing browser golden (mean error 0.46 / 4, bad pixels 1.41% / 2%). +- The candidate is local. Pin a reachable revision in release and CI once it is published. Native + presentation still needs target-platform verification. In particular, Windows currently destroys + the HWND after removing the logical window; coordinate destruction with queued Vulkan retirement + before enabling the backend by default. Linux and Android surface lifetime and Apple backend + acceptance remain open. + +The Metal and Web publication candidate is `8be633b5be0f747e0aa4e030fcad03c4ca17908f` on +`codex/adaengine-metal-web`. The bundle at `.build-tools/saga-metal-web-release.bundle` contains +the Metal and Web changes and the shared presentation contract. Its relevant files are identical +to the validation checkout at `dc597ff539572846edf4c821fb3b14888c8a4b2a`; the separate candidate +omits the Vulkan backend changes. The SAGA Metal compute readback and surface ownership tests passed on +an Apple M3 Pro with graphics access. AdaEngine's `SAGAMetalWindowTests` read back the expected +pixels and received positive presentation timestamps at both window sizes. All five +`PixelPerfectMetalTests` passed on SAGA Metal, covering sprites, exact scaling, and UI composition. +The Metal option resolves no Swan dependency. + +The initial 3D run reproduced a stack overflow in SAGA's MSL writer for SSAO. After replacing +recursive expression and cast emission, both `Render3DGPUTests/SagaNative` cases passed, including +the Shader Graph material. The stored sphere reference differs from both current renderers. +Against a fresh legacy Metal run on the same GPU, SAGA's frame differs in 50 of 1,228,800 channel +bytes, by one step at most; no pixels exceed the two-step tolerance. The full legacy golden suite +also reports a wireframe mismatch and a missing shaded-wireframe reference. These failures do not +establish a regression in SAGA. Keep the checked-in references for separate baseline maintenance. + +The 87 targeted expression, conversion and compiler-budget tests passed. The freshly exported +`AdaWebPlayer` also passed the existing `SAGASmoke` golden in Chromium again (mean error 0.46 / 4, +bad pixels 1.41% / 2%). The final companion commit adds package deployment targets and excludes +hardware-only diagnostics on simulators; `SAGAMetal` builds for iOS and tvOS simulators. This +was followed by successful `AdaEngine` builds against the iOS Simulator, tvOS Simulator, and Apple +TV SDKs with SAGAMetal enabled. The tvOS build needed platform guards for pasteboard, edit menu, +pointer interactions, and screen brightness. The standard build with legacy Metal also passed +against the Apple TV SDK. The temporary iPhone simulator app launched through +SAGAMetal and logged a rejected text pass because its GPU does not support filtering 32-bit float +textures. The font loader now uses RGBA16F for the GPU upload when filtering RGBA32F is unavailable. +The corrected iPhone simulator run displayed the text and colored UI. The renderer log confirmed +the text pass and presentation completed. The Apple TV target also compiles with this fallback. +The iPhone 3D scene also displayed a lit sphere and its shadow. The PBR shader packs the four +material scalars into one interpolator, and the shadow passes use integer loads from the 32-bit +atlases. The iPhone log showed presentation and no SAGA errors. On tvOS Simulator 26.2, an app +built with SwiftPM and the tvOS 26.5 SDK displayed text and the 3D scene with shadows. The +1920×1080 capture and SAGA queue log confirm the 3D presentation. Physical Apple TV runtime +verification remains open. + +The current AdaEngine checkout was rebuilt with the Metal and Web candidate using Xcode 26.6 +and the tvOS 26.5 device and simulator SDKs. The build excludes UIKit pointer and edit-menu +APIs on tvOS and uses the clipboard's in-process fallback. The rebuilt tvOS client ran the +3D shadow scene on the Apple TV Simulator, with SAGA pipeline logs and a captured frame. + +The Web exporter uses the same resolved SAGA package for Swift code and JavaScript assets. The +release candidate export passed the browser golden, and the final WASM and JavaScript files matched +the checked artifacts byte for byte. A fresh export after the font atlas fallback passed the same +golden with unchanged error metrics. A further export after the PBR and shadow changes passed +with the same metrics. The macOS incremental build with Swift 6.3.2 reproduced a SIGBUS. A clean +build with Xcode Swift 6.3.3 passed PBR parity, Shader Graph and the WESL library tests. The PBR +and Shader Graph cases and all five 2D cases also passed in that build with diagnostics disabled. +The compatibility branch has not been published. + +The command completion regression reproduced early callbacks on both a command buffer and an empty +frame fence. Both cases now pass. MovieMaker ran on SAGAMetal and verified the pixels of two +recorded frames across a camera cut. A fresh Web export also passed the browser golden after the +completion change. + +The failure-path regression reproduced two additional faults when SAGA device creation throws: +pending completion callbacks were dropped and an already-attached window surface was not removed. +The queue now runs cleanup in submission order without a device and completes skipped command +callbacks. Both failure cases and the successful submission-retirement cases passed. + +## Default renderer rollout + +The rollout selects SAGAMetal by default on macOS, iOS, and tvOS, pins SAGA for Metal and +Web builds, and updates Apple CI and tooling. The legacy Metal switch and the local SAGA +checkout override remain available during the transition. +The Apple validation job builds the default renderer for macOS and the Apple TV SDK. +Its legacy Metal comparison explicitly selects that renderer. +The iPad job checks out AdaEngine under its SwiftPM package identity and uses the pinned +portable Sloppy runtime at `0d8fd03d0269a480b6af3fb4f40333c319e2c398`. The newer runtime +checkout imports SloppyA2UI without declaring it in the portable package. Separating Apple +and Android product conditions makes Xcode link the portable products into iPad Studio. +The iPad simulator build succeeded with the pinned SAGA and Sloppy checkouts. On iPadOS +Simulator 26.3, Studio compiled the SAGA UI, text and upscale pipelines and displayed the +project launcher. The CI script waits for the upscale pipeline before taking a screenshot. + +Two isolated source copies resolved `8be633b5be0f747e0aa4e030fcad03c4ca17908f` through a local +Git mirror with the canonical GitHub URL in their lockfiles. The macOS copy selected SAGAMetal +without a renderer flag and presented frames before and after a resize. The window check runs +with its explicit smoke flag and skips otherwise. The Web copy exported the player, shipped +the exact JavaScript assets from the pinned revision, and passed the Chromium golden at mean +error 0.46 and 1.41% bad pixels. The manifest matrix also passed for legacy Metal, headless, +Android, and Vulkan opt-in. + +A separate tvOS client of the rollout preview resolved the same pin and launched without a +renderer flag or checkout override. Its startup assertion confirmed SAGAMetal, the Apple TV +Simulator presented the 3D shadow scene, and the SAGA log reported no device errors. The pinned +Web export also passed its golden after the queue failure-path change. + +The standalone macOS Studio build used the rollout preview and the pinned AdaMCP revision +from a sibling checkout. SwiftPM rejects a remote AdaMCP that refers to the local AdaEngine +package. The first built app could not load the bundled Lore library because the staged +SwiftPM executable still searched the build directory. Both macOS Xcode targets now +relocate that path to the app's Frameworks directory. The standalone build passed strict +code-signing verification, its packaged CLI returned its version, and its window displayed +the Shader Graph project. SAGA logged the UI, text and sprite pipelines with the renderer +override unset. + +The packaged CLI also reproduced a crash before the native player build. Scene conversion +decoded textured components before a render device existed. The exporter now initializes a +headless render device when invoked without Studio's render world. A second issue in the +rollout preview suppressed SAGAMetal in native exports because the exporter disables Swan. +The manifest now treats disabling Swan independently from headless CI. The packaged CLI +exported the Shader Graph sprite fixture with the bundled SDK. The player resolved the +pinned SAGA revision through the local mirror, launched without a renderer override, and +presented the textured sprite through SAGAMetal. + +The packaged Studio also exported the same project to Web from its bundled SDK under +`Ada Studio.app`. The export plugin now passes decoded filesystem paths to build tools and +finds SAGA in the game's resolved transitive dependencies. The WASI build uses Swift +Collections 1.4.1 with Swift 6.3.2 to avoid a runtime ABI trap. Debug game exports use +AdaWebPlayer's 4 MiB stack budget. The Chromium WebGPU smoke passed with the pinned SAGA +host assets, 1,924 orange sprite pixels in both captured frames, and frame submission +continuing after a viewport resize. Chromium reported an unreachable-code shader warning. +The CLI's default Release export also launched in Chromium. It presented the textured +sprite before and after resizing, with SAGA's frame counter advancing from 4 to 12. +Both exported JavaScript host assets match the pinned checkout byte for byte. +The Apple workflow preview adds a `studio-web` job that builds standalone Studio and +exports the Shader Graph fixture through its packaged CLI. The browser check verifies +sprite pixels before and after a change to the SAGA surface size. It passed locally on +both Debug and Release exports. The existing AdaWebPlayer golden also passed with +mean error 0.46 and 1.41% bad pixels. + +The candidate is published on `AdaEngine/SAGA` at `codex/adaengine-metal-web`. The local tooling test suite reported +one existing macOS installation fixture error when copying system file flags; the same test +fails on the unchanged tooling. The WASM doctor check for the pinned dependency passed. diff --git a/Documentation/Tooling.md b/Documentation/Tooling.md index 478075852..a9fe383c8 100644 --- a/Documentation/Tooling.md +++ b/Documentation/Tooling.md @@ -14,10 +14,28 @@ python ada_tool.py install studio python ada_tool.py install studio --debug --launch ``` +## Local SAGA checkout + +Apple builds with SAGAMetal and browser exports resolve a pinned SAGA revision +through SwiftPM. Set `ADAENGINE_SAGA_PACKAGE_PATH` to an absolute path to +develop against a local checkout. In WSL use the Linux path. Native Vulkan +development and native WebGPU compiler builds use the sibling SAGA checkout +unless this override is set. + +```powershell +$env:ADAENGINE_SAGA_PACKAGE_PATH = 'D:/Development/SAGA' +$env:ADAENGINE_VALIDATE_WGSL = '1' +swift test --filter WGSLShaderCompilerTests +``` + +The test suite translates fresh SPIR-V for both shader stages and verifies the +WGSL entry point and binding reflection. GLSL source remains supported during +the compiler migration. + ## Install and run Studio Both configurations are Standalone distributions, including resources, runtime, -Tint and the source BuildSDK. Installation without `--source` builds the current +and the source BuildSDK. Installation without `--source` builds the current checkout; it does not download the latest GitHub release. Debug selects the debug compiler configuration and has its own installation directory and shortcut. @@ -67,14 +85,14 @@ python ada_tool.py prepare --skip-system ``` `doctor` does not install or download anything. It checks the selected Swift -version, native build tools, Tint hash and a real SPIR-V conversion, Lore, WASM +version, native build tools, the local SAGA checkout, Lore, WASM SDK/WSL, frontend tools and Cloud panel inputs according to the profile. JSON goes to stdout; diagnostic subprocess output goes to stderr. Exit codes: 0 ready, 1 missing requirement or operation failure, 2 usage error, 130 interrupted. It cannot establish GPU/desktop/runtime correctness by inspecting dependencies. With no component arguments, `prepare` selects Studio, WASM and playground. -It can download large toolchains and build Tint. System package managers may +It can download large toolchains. System package managers may request administrator access. The command uses WinGet/Visual Studio 2022 on Windows, Homebrew on macOS and apt on Debian/Ubuntu. Install Homebrew and full Xcode first on macOS. For another Linux distribution, install the packages from @@ -101,7 +119,7 @@ and WASM Swift 6.3.2. This does not change the Swift 6.2 language mode. Swiftly installs the selected Unix toolchain without changing the user's global default or `.swift-version`. The CLI saves its explicit executable path for later commands. The WASM SDK and Linux Dawn archive are checked against pinned SHA-256 values; -Tint and Lore keep their own version/hash manifests. A compatible existing Node +Lore keeps its own version/hash manifest. A compatible existing Node is reused; otherwise the pinned version is installed in the CLI cache with its official SHA-256 manifest. pnpm comes from the frontend's `packageManager` pin. Existing explicit native dependency overrides are retained. @@ -262,3 +280,69 @@ The suite covers real filesystem installation/upgrade/rollback, invalid inputs, archive traversal, hash failures, static HTTP routing and a small real SwiftPM build (when the pinned compiler is installed). Platform-specific installation and complete Studio/GPU/WASM exports still need verification on their own hosts. + +## Browser renderer (SAGA) + +Web exports render through the pinned SAGA package and ship its JavaScript WebGPU host as `saga-webgpu.mjs`. +The exporter copies `web/webgpu.mjs` from the SwiftPM dependency that provides `SAGAWebHost`. +It also copies `web/golden.mjs` when available for frame comparisons. For migration builds, point +the override at the companion checkout: + +```sh +export ADAENGINE_SAGA_PACKAGE_PATH=/absolute/path/to/saga-companion +``` + +Add `?sagadebug` to the page URL to wait for every submission and print SAGA device errors and per-pipeline binding +reports to the console. Web export uses JavaScriptKit's generic BridgeJS runtime and resolves SAGA without Swan or Dawn. + +Golden-frame check: pack `Tests/WebGolden/SAGASmoke` into an exported `AdaWebPlayer` and run +`node script/web_golden_saga.mjs ` (needs `playwright-core` and a WebGPU Chromium; `--record` replaces the +reference). Pass `--actual=` to save the captured frame. See [the smoke scene README](../Tests/WebGolden/SAGASmoke/README.md). + +## SAGA Metal development + +AdaEngine selects SAGAMetal by default on macOS, iOS and tvOS. Set `ADAENGINE_SAGA_METAL=0` +to use the legacy Metal backend during the transition. The following tests exercise SAGAMetal: + +```sh +ADAENGINE_SAGA_METAL_WINDOW_SMOKE=1 swift test --filter SAGAMetalWindowTests +ADAENGINE_GOLDEN_SAGA=1 swift test --filter Render3DGPUTests/SagaNative +ADAENGINE_PIXEL_PERFECT_SAGA_SMOKE=1 swift test --filter PixelPerfectMetalTests +``` + +The window test requires a macOS desktop session with access to the Metal GPU. The renderer +uses SAGA to compile linked WGSL to MSL and presents through the existing MetalKit views. + +To build the engine for Apple TV and the Apple TV Simulator, use Xcode with both tvOS SDKs +installed. For SAGAMetal, use the environment settings above with a SAGA checkout that also +declares tvOS support. + +```sh +swift build --target AdaEngine --scratch-path .build-tools/tvos-simulator \ + --triple arm64-apple-tvos18.0-simulator --sdk "$(xcrun --sdk appletvsimulator --show-sdk-path)" +swift build --target AdaEngine --scratch-path .build-tools/tvos-device \ + --triple arm64-apple-tvos18.0 --sdk "$(xcrun --sdk appletvos --show-sdk-path)" +``` + +To compare the same 3D scene on both Metal implementations, capture the legacy result and then +run SAGA in a fresh test process. Use a dedicated output directory: + +```sh +export ADAENGINE_COMPARE_SAGA_METAL=1 +export ADAENGINE_GOLDEN_OUTPUT=/absolute/path/to/metal-comparison +mkdir -p "$ADAENGINE_GOLDEN_OUTPUT" +ADAENGINE_GOLDEN_METAL=1 swift test --filter captureLegacyMetalReferenceForSAGA +ADAENGINE_GOLDEN_SAGA=1 swift test --filter theLitSphereRowRendersWithTheWESLShaders +``` + +## Native SAGA Vulkan development + +Set `ADAENGINE_SAGA_NATIVE=1` to build the Windows, Linux, or Android renderer +against SAGA Vulkan. The native build uses the system Vulkan driver. The SwiftPM +build resolves SAGA from the sibling checkout or `ADAENGINE_SAGA_PACKAGE_PATH`. +Use a revision that supports attaching a Vulkan surface after device creation. +Set `ADAENGINE_GOLDEN_SAGA=1` and `ADAENGINE_SAGA_WINDOWLESS=1` for the offscreen +3D checks, then run `swift test --filter SagaNative`. Set `ADAENGINE_GOLDEN_OUTPUT` +to an existing directory to collect the rendered PNGs. + +For Android APK exports and emulator setup, see [Native Android apps](../Tools/Android/README.md). diff --git a/Editor/Package.swift b/Editor/Package.swift index de125e480..63c7436f1 100644 --- a/Editor/Package.swift +++ b/Editor/Package.swift @@ -45,10 +45,11 @@ let hasSloppyRuntime = FileManager.default.fileExists(atPath: sloppyRuntimeURL.a let sloppyRuntimePackage: [Package.Dependency] = hasSloppyRuntime ? [.package(name: "SloppyRuntimePortable", path: sloppyRuntimeURL.path)] : [] +let sloppyRuntimePlatforms: [Platform] = isAndroidStudio ? [.android] : [.iOS, .visionOS] let sloppyRuntimeTarget: [Target.Dependency] = hasSloppyRuntime ? [ - .product(name: "SloppyRuntime", package: "SloppyRuntimePortable", condition: .when(platforms: [.iOS, .visionOS, .android])), - .product(name: "Protocols", package: "SloppyRuntimePortable", condition: .when(platforms: [.iOS, .visionOS, .android])), + .product(name: "SloppyRuntime", package: "SloppyRuntimePortable", condition: .when(platforms: sloppyRuntimePlatforms)), + .product(name: "Protocols", package: "SloppyRuntimePortable", condition: .when(platforms: sloppyRuntimePlatforms)), ] : [] // AdaMCP's dependency graph includes Git revisions, so SwiftPM requires a revision here. diff --git a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json index 0ce5c1478..ff4da4bb9 100644 --- a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json +++ b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json @@ -315,7 +315,7 @@ }, { "kind": "text", - "text": "• Building Ada\n• Contributing to Ada\n• AdaUI View Identity\n• Loading images and adding formats\n• Authoring skeletal animation\n• Animation IK and pose modifiers\n• Game diagnostics overlay\n• Movie Maker\n• Pipelined Rendering\n• Terrain\n• visionOS Windowed Mode", + "text": "• Building Ada\n• Contributing to Ada\n• AdaUI View Identity\n• Loading images and adding formats\n• Authoring skeletal animation\n• Animation IK and pose modifiers\n• Game diagnostics overlay\n• Movie Maker\n• Pipelined Rendering\n• Terrain\n• visionOS Windowed Mode\n• Compile modular WESL shaders", "level": 0, "links": [ { @@ -361,6 +361,10 @@ { "title": "visionOS Windowed Mode", "destination": "VisionOSWindowed" + }, + { + "title": "Compile modular WESL shaders", + "destination": "WESLShaders" } ] } @@ -4625,6 +4629,199 @@ } ] }, + { + "id": "WESLShaders", + "title": "Compile modular WESL shaders", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/WESLShaders.md", + "blocks": [ + { + "kind": "text", + "text": "Link local shader modules and compile feature variants to WGSL with the AdaEngine host tool.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "AdaEngine links the modules in Swift and validates the result with the local SAGACompiler. The shader syntax follows the WESL specification snapshot.", + "level": 0, + "links": [ + { + "title": "WESL specification snapshot", + "destination": "https://github.com/webgpu-tools/wesl-spec/tree/37bf3f3a2164781040e7a42fc6529cb34e2c059b" + } + ] + }, + { + "kind": "heading", + "text": "Link two shader files", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Save a helper as `shaders/common.wesl`.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "fn shade() -> vec4f { return vec4f(1); }", + "language": "wgsl", + "links": [] + }, + { + "kind": "text", + "text": "Save the entry module as `shaders/main.wesl`.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import package::common::shade as baseColor;\n\n@if(textured)\n@group(0) @binding(0) var image: texture_2d;\n\n@fragment fn fragmentMain() -> @location(0) vec4f {\n @if(textured)\n return textureLoad(image, vec2i(0), 0) * baseColor();\n @else\n return baseColor();\n}", + "language": "wgsl", + "links": [] + }, + { + "kind": "text", + "text": "Enable the host export tools and compile both variants.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "export ADAENGINE_WEB_EXPORT=1\nswift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled\nswift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled --feature textured", + "language": "sh", + "links": [] + }, + { + "kind": "text", + "text": "In PowerShell, set the variable with `$env:ADAENGINE_WEB_EXPORT = '1'`.\nThe tool writes `compiled/main.wgsl` and `compiled/main.textured.wgsl`. The textured variant declares a texture at group 0, binding 0. Use `--wesl-root` when the main module lives below the package root. Use `--wesl-dependency name=directory` to register a package for imports starting with `name::`. Inside that dependency, `package::` refers to its own root.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "On SAGA and native WebGPU builds with WESL support, `ShaderModule.create(from:)` loads the default variant from a `.wesl` file. Use `ShaderModule.createWESL(from:packageRoot:features:)` to supply a package root or enable features. A shader module can have one entry point per active stage. The browser uses linked WGSL, and SAGA compiles the same linked source to MSL for Metal or SPIR-V for Vulkan. SAGAMetal is the default renderer on macOS, iOS, and tvOS.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "When porting a GLSL material, move its sampler and texture into separate WGSL bindings and pass vertex outputs explicitly between stages. The fragment example above uses `textureLoad` for integer texel coordinates. For normalized, filtered sampling, declare a `sampler` at a separate binding and use `textureSample(image, imageSampler, uv)`. Update the material's binding numbers to match the WESL declarations and compile every feature combination the material uses.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The built-in quad, circle, line, and linear gradient use WESL on WebGPU. The sprite, instanced sprite, Spine, text, and tilemap pipelines also use WESL on WebGPU. These shaders import the shared view projection from `ada_engine::view`. The runtime and web exporter register this package from the AdaRender shader resources. The 2D composite, point, and directional lighting passes share their fullscreen vertex stage through `light2d_fullscreen.wgsl`. Web export links their default variants on the host and packages the WGSL stages for browser startup. In automatic web export, the entry file's directory is the local package root. Backends without WESL support load the corresponding GLSL shaders.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Fullscreen presentation, local shadow atlas clears, and temporal tone mapping use a common fullscreen vertex module. The temporal tone mapping pass imports the output transform and color grading modules. Sky motion, FXAA, SSAO, the 3D debug view, and temporal output depth also use the fullscreen module. The depth pass and portable temporal reconstruction use depth texture bindings and integer texel loads. Reconstruction reads its R32Float history without filtering. Bloom, automatic exposure, depth of field, and camera effects use the same fullscreen vertex module. Camera effects also use the shared display dithering function.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The screen-space reflection pass and path-tracing composite import environment fog, exposure, bloom, and grading from the shared composite module.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Directional shadows and motion vectors use WESL base shaders and `ADA_MORPH` variants. They share mesh morphing, face and fade visibility, and base-color alpha sampling. Web export packages the linked variant with its feature name, for example `directional_shadow_3d.wesl.ADA_MORPH.vert.wgsl`.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Dynamic irradiance probe updates use integer texel reads for transport, history, and the local shadow atlas.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The meshlet map exports the base shader and the three combinations of its reflection response and surface attachments.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The linker reports linking errors with the affected file path. This initial implementation supports explicit local imports and conditional declarations. It rejects cyclic imports and local names that shadow linked bindings. Web export emits named variants for built-in pipelines that declare them. Caching linked artifacts and remapping compiler diagnostics to the original source are pending.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Emit native backend code with SAGA", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "The host tool can compile linked WESL directly through SAGA to Metal source or binary SPIR-V.\nEnable the host tools with `ADAENGINE_WEB_EXPORT=1`, then use the same source package and feature flags:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "swift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled --target msl\nswift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled --target spirv --feature textured", + "language": "sh", + "links": [] + }, + { + "kind": "text", + "text": "For the fragment-only example above these commands write `main.frag.msl` and\n`main.textured.frag.spv`. Vertex and compute stages use `.vert` and `.comp` suffixes.\nSPIR-V output is a little-endian binary module, not a GLSL string or a WGSL sidecar.\nThe default target remains WGSL. `--web-stages` is only valid with WGSL output.\nA bundled module must have at most one entry point of each stage; the tool compiles every stage\nbefore writing the native batch, so a compiler failure does not publish a partial batch.\nFilesystem failures during writing can still leave individual completed files.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "For in-process integration, `WESLBackendCompiler.compile(_:to:)` takes the artifact returned by\n`WESLHostCompiler.compile` and returns each emitted stage, its source and generated entry-point names,\nits code bytes and its full SAGA render/compute reflection. Use the reflected Metal buffer, texture\nand sampler indices: WGSL binding numbers are not necessarily Metal resource slots.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Custom `ReflectedMaterial` types may override `shaderModule()` to supply a complete module instead of\nseparate legacy vertex/fragment sources. The default returns `nil`, preserving the current source path.\n`CustomMaterial` retains a supplied module and uses per-instance material identity; it does not recompile\nits stages as GLSL. Such a module must already have the correct reflection and selected feature variant:\npassing shader defines afterward is rejected, and serializing a module-backed material without its\noriginal source is unsupported. Hook loading failures propagate when creating the material's module.\nThe native WebGPU WESL loader now reads SAGA-validated IR for uniform-buffer\nreachability and minimum byte sizes, including matrix padding. This layout query does not regenerate\nWGSL: it follows function calls and global references in the selected entry's IR and uses validated\nstruct spans and array strides (with WGSL scalar/vector/matrix sizes for non-struct roots). `WESLBackendCompiler.webGPUUniformBuffers`\nexposes those logical group/binding layouts without substituting Metal slots. Uniform binding arrays\nare rejected until a runtime adapter exists. If a reflected uniform cannot be matched by the transitional\nname parser, loading fails rather than allocating a zero-sized buffer.\nThis is not complete reflection integration: resource names, member metadata and non-uniform resources\nstill depend on the existing parser; material property-name mapping and browser sidecar metadata remain pending.\nThe new UI shadow source is therefore not yet selected by the production shadow material.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "These command-line native outputs are code-generation artifacts, **not ready-to-load material packages**.\nThe command does not serialize the runtime reflection, cache identity, vertex-stream mapping or pipeline state.\nThe existing runtime still selects GLSL on backends without an integrated WESL loader.\nEmitting or compiling Metal source does not prove draw-time binding correctness or pixel parity.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Full GLSL removal is an in-progress breaking migration", + "level": 3, + "links": [] + }, + { + "kind": "text", + "text": "The approved destination is WESL/WGSL input, SAGA compilation and reflection, and no first-party GLSL fallback.\nThis is not yet a completed cutover: user GLSL inputs remain supported by the old production loader until\nits replacement and backend verification land. Do not delete working project assets based only on the new tool option.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "When porting a shader, replace GLSL stage pragmas with WGSL entry-point attributes and use explicit\nWESL imports instead of preprocessor includes. Port compile-time interface changes to WESL conditions,\nkeep buffer byte layouts and material bindings aligned with the host types, then compile and exercise every variant.\nFor example, the shared `shade` helper and `@fragment` entry point above replace a GLSL include and\nfragment `main`; `@if(textured)` replaces a structural preprocessor condition, not a runtime branch.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Graph-backed Shader Graph assets will be regenerated through the new generator. Archives retaining only\nGLSL source will require a manual WESL/WGSL port and rebake; no automatic lossless source conversion is promised.\nThe final loader diagnostic and archive-version migration are still pending implementation.", + "level": 0, + "links": [] + } + ] + }, { "id": "GameDiagnosticsOverlay", "title": "Game diagnostics overlay", diff --git a/Editor/Sources/AdaEditor/Assets/Shaders/agent_activity.wesl b/Editor/Sources/AdaEditor/Assets/Shaders/agent_activity.wesl new file mode 100644 index 000000000..1401a21fc --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Shaders/agent_activity.wesl @@ -0,0 +1,92 @@ +import ada_engine::view::projectPosition; + +// WESL port of agent_activity.glsl. Coordinates, size, radius and width are in logical pixels. +// The fragment returns PREMULTIPLIED RGBA; blend with ONE, ONE_MINUS_SRC_ALPHA. + +struct EditorAgentGlowMaterial { + u_Color: vec4f, + u_Geometry: vec4f, + u_Style: vec4f, +} + +struct AgentGlowVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +} + +@group(0) @binding(0) var material: EditorAgentGlowMaterial; + +@vertex +fn editor_agent_activity_vertex(@location(0) position: vec4f, @location(1) color: vec4f, @location(2) uv: vec2f) -> AgentGlowVertex { + return AgentGlowVertex(projectPosition(position), color, uv); +} + +fn agentRGBToHSV(c: vec3f) -> vec3f { + let k = vec4f(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + let p = mix(vec4f(c.bg, k.wz), vec4f(c.gb, k.xy), step(c.b, c.g)); + let q = mix(vec4f(p.xyw, c.r), vec4f(c.r, p.yzx), step(p.x, c.r)); + let d = q.x - min(q.w, q.y); + return vec3f(abs(q.z + (q.w - q.y) / (6.0 * d + 0.00001)), d / (q.x + 0.00001), q.x); +} + +fn agentHSVToRGB(c: vec3f) -> vec3f { + let p = abs(fract(c.xxx + vec3f(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - vec3f(3.0)); + return c.z * mix(vec3f(1.0), clamp(p - vec3f(1.0), vec3f(0.0), vec3f(1.0)), c.y); +} + +fn agentRoundedBox(p: vec2f, halfSize: vec2f, radius: f32) -> f32 { + let q = abs(p) - halfSize + vec2f(radius); + return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +fn agentActivityGlow( + position: vec2f, size: vec2f, time: f32, accent: vec3f, + activity: f32, intensity: f32, width: f32, radius: f32, hueSpread: f32 +) -> vec4f { + let halfSize = max(size * 0.5, vec2f(1.0)); + let p = position - halfSize; + let corner = clamp(radius, 0.0, min(halfSize.x, halfSize.y)); + let sdf = agentRoundedBox(p, halfSize - vec2f(1.0), corner); + let inside = 1.0 - smoothstep(-0.65, 0.65, sdf); + let distanceToEdge = max(-sdf, 0.0); + let direction = p / halfSize; + // All angular frequencies are integers, so the atan seam remains continuous. + let angle = select(atan2(direction.y, direction.x), 0.0, length(direction) < 0.0001); + let phase = angle - time * 0.38; + let wave = 0.5 + 0.5 * sin(phase * 3.0 + 0.8 * sin(angle * 2.0 + time * 0.31)); + let swellBase = 0.5 + 0.5 * sin(phase * 2.0 - 0.65); + let swell = swellBase * swellBase; + let band = max(width, 1.0) * (0.38 + 0.62 * wave); + let halo = exp(-distanceToEdge / band) * (0.15 + 0.26 * swell); + // A broad, blurred crest rather than a sharp second outline. + let crestDistance = distanceToEdge - band * (0.24 + 0.30 * swell); + let crestScale = crestDistance / (band * 0.48 + 1.0); + var crest = exp(-(crestScale * crestScale)); + crest = crest * ((0.06 + 0.13 * swell) * exp(-distanceToEdge / (band * 1.8))); + let rim = exp(-distanceToEdge / 1.15) * (0.28 + 0.56 * swell); + let nearRim = exp(-distanceToEdge / 5.0) * (0.07 + 0.13 * wave); + // The center is strictly transparent, even for oversized width settings. + let edgeMask = 1.0 - smoothstep( + min(width * 1.9, min(halfSize.x, halfSize.y) * 0.45), + min(width * 3.0, min(halfSize.x, halfSize.y) * 0.8), + distanceToEdge + ); + let hsv = agentRGBToHSV(clamp(accent, vec3f(0.0), vec3f(1.0))); + let shift = hueSpread * sin(phase + 0.7 * sin(angle * 2.0 + time * 0.2)); + let tint = agentHSVToRGB(vec3f(fract(hsv.x + shift), hsv.y, hsv.z)); + let color = mix(tint, vec3f(1.0), clamp(rim * 0.50, 0.0, 0.45)); + var alpha = clamp((halo + crest + rim + nearRim) * intensity, 0.0, 0.94); + alpha = alpha * inside * edgeMask * clamp(activity, 0.0, 1.0); + return vec4f(color * alpha, alpha); +} + +@fragment +fn editor_agent_activity_fragment(input: AgentGlowVertex) -> @location(0) vec4f { + let size = material.u_Geometry.xy; + let position = input.uv * size; + return agentActivityGlow( + position, size, material.u_Geometry.z, material.u_Color.rgb, + 1.0, material.u_Style.x, material.u_Geometry.w, material.u_Style.z, material.u_Style.y + ) * material.u_Style.w; +} diff --git a/Editor/Sources/AdaEditor/Assets/Shaders/mobile_voice_orb.wesl b/Editor/Sources/AdaEditor/Assets/Shaders/mobile_voice_orb.wesl new file mode 100644 index 000000000..a50064631 --- /dev/null +++ b/Editor/Sources/AdaEditor/Assets/Shaders/mobile_voice_orb.wesl @@ -0,0 +1,119 @@ +import ada_engine::view::projectPosition; + +// WESL port of mobile_voice_orb.glsl, itself ported from Sloppy Desktop Companion's DesktopOrb.metal. +// Keep the volume, ribbons, palette, bloom and edge fade identical to the companion Bubble. + +struct MobileVoiceOrbMaterial { + u_Geometry: vec4f, + u_Style: vec4f, +} + +struct OrbVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +} + +@group(0) @binding(0) var material: MobileVoiceOrbMaterial; + +@vertex +fn mobile_voice_orb_vertex(@location(0) position: vec4f, @location(1) color: vec4f, @location(2) uv: vec2f) -> OrbVertex { + return OrbVertex(projectPosition(position), color, uv); +} + +fn orbHash(point: vec3f) -> f32 { + var p = fract(point * 0.3183099 + vec3f(0.17, 0.31, 0.53)); + p = p * 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); +} + +fn orbNoise(p: vec3f) -> f32 { + let i = floor(p); + var f = fract(p); + f = f * f * (vec3f(3.0) - 2.0 * f); + return mix( + mix( + mix(orbHash(i), orbHash(i + vec3f(1.0, 0.0, 0.0)), f.x), + mix(orbHash(i + vec3f(0.0, 1.0, 0.0)), orbHash(i + vec3f(1.0, 1.0, 0.0)), f.x), + f.y + ), + mix( + mix(orbHash(i + vec3f(0.0, 0.0, 1.0)), orbHash(i + vec3f(1.0, 0.0, 1.0)), f.x), + mix(orbHash(i + vec3f(0.0, 1.0, 1.0)), orbHash(i + vec3f(1.0, 1.0, 1.0)), f.x), + f.y + ), + f.z + ); +} + +fn orbRotate(p: vec2f, a: f32) -> vec2f { + let c = cos(a); + let s = sin(a); + return vec2f(c * p.x + s * p.y, -s * p.x + c * p.y); +} + +fn mobileVoiceOrb(coordinates: vec2f, resolution: vec2f, time: f32, level: f32) -> vec4f { + let uv = (coordinates - 0.5 * resolution) / min(resolution.x, resolution.y) * 2.0; + let t = time * 0.23; + let breath = 1.0 + 0.025 * sin(time * 0.8) + level * 0.105 + level * 0.045; + let q = uv / (0.70 * breath); + let r = length(q); + var light = vec3f(0.0); + let a = atan2(q.y, q.x); + let haloOuter = (r - 0.94) / 0.24; + let halo = exp(-pow(r / 1.03, 4.0)) * 0.09 + exp(-(haloOuter * haloOuter)) * 0.13; + let haloColor = mix(vec3f(0.20, 0.35, 0.75), vec3f(0.64, 0.54, 0.77), 0.5 + 0.5 * sin(a * 2.0 - t)); + light = light + haloColor * halo; + // Integrate luminous flowing ribbons through a soft spherical volume. + let depth = sqrt(max(0.0, 1.12 - r * r)); + for (var i = 0; i < 20; i++) { + let z = (-1.0 + 2.0 * (f32(i) + 0.5) / 20.0) * depth; + var p = vec3f(q, z); + let rotatedXZ = orbRotate(vec2f(p.x, p.z), t * 0.37); + p = vec3f(rotatedXZ.x, p.y, rotatedXZ.y); + let rotatedXY = orbRotate(vec2f(p.x, p.y), t * 0.20); + p = vec3f(rotatedXY.x, rotatedXY.y, p.z); + let radius = length(p); + let n = orbNoise(p * 2.9 + vec3f(t * 0.55, -t * 0.38, t * 0.25)); + let n2 = orbNoise(p * 5.6 - vec3f(t * 0.2, t * 0.45, 0.0)); + let angle = atan2(p.y, p.x); + let flow = angle * 4.0 + p.z * 3.2 + radius * 2.8 - t * 1.35 + (n - 0.5) * 4.2; + let ribbon = pow(0.5 + 0.5 * sin(flow), 5.0); + let secondary = pow(0.5 + 0.5 * sin(angle * 3.0 - p.z * 4.5 - t * 0.8 + n2 * 3.0), 9.0); + let envelope = 1.0 - smoothstep(0.72, 1.08 + (n - 0.5) * 0.09, radius); + let cloud = 0.22 + 0.78 * n; + let density = (ribbon * 0.92 + secondary * 0.32 + 0.12) * envelope * cloud; + let hue = 0.5 + 0.5 * sin(flow * 0.45 + p.z * 2.0 + t * 0.7 + n * 3.0); + let blue = vec3f(0.055, 0.25, 0.96); + let cyan = vec3f(0.16, 0.88, 1.02); + let pearl = vec3f(0.87, 0.77, 0.96); + var color = mix(blue, cyan, smoothstep(0.15, 0.76, hue)); + color = mix(color, pearl, pow(0.5 + 0.5 * sin(p.y * 3.0 + p.z * 2.0 + t * 0.9 + n2 * 2.0), 6.0) * 0.75); + color = mix(color, vec3f(0.58, 0.38, 1.0), level * 0.23); + light = light + color * density * depth * 0.13 * (1.0 + level * 0.95 + level * 0.35); + } + // A diffuse luminous core and optical bloom, without a hard silhouette. + let core = exp(-r * r * 15.0) * (0.27 + 0.09 * sin(t * 2.0) + level * 0.2); + light = light + vec3f(0.39, 0.75, 1.0) * core; + let veil = exp(-pow(r / 0.86, 6.0)) * 0.12; + light = light + vec3f(0.10, 0.24, 0.72) * veil; + let beamAngle = a + t * 0.6 + 0.45 * sin(r * 3.0 - t) + 0.22 * sin(a * 3.0 + t); + let beams = pow(0.5 + 0.5 * sin(beamAngle * 5.0), 4.0); + light = light + vec3f(0.18, 0.66, 0.91) * beams * exp(-r * r * 2.8) * (1.0 - smoothstep(0.8, 1.1, r)) * 0.31; + light = vec3f(1.0) - exp(-light * 3.4); + let grain = (orbHash(vec3f(coordinates, 7.0)) - 0.5) * 0.018; + light = max(vec3f(0.0), light + vec3f(grain * min(1.0, halo * 3.0 + length(light)))); + let alpha = clamp(max(max(light.r, light.g), light.b) * 1.3, 0.0, 1.0); + // Fade both premultiplied color and alpha before reaching the canvas/ring edge. + let edgeFade = 1.0 - smoothstep(0.68, 1.0, length(uv)); + return vec4f(light, alpha) * edgeFade; +} + +@fragment +fn mobile_voice_orb_fragment(input: OrbVertex) -> @location(0) vec4f { + let size = material.u_Geometry.xy; + return mobileVoiceOrb( + vec2f(input.uv.x, 1.0 - input.uv.y) * size, + size, material.u_Geometry.z, material.u_Geometry.w + ) * material.u_Style.x; +} diff --git a/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptNativeExporter.swift b/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptNativeExporter.swift index 4b63284dd..2c0f1254c 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptNativeExporter.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptNativeExporter.swift @@ -293,6 +293,13 @@ actor EditorAdaScriptNativeExporter { @MainActor private static func convertScenes(at assets: URL, originalRoot: URL, sources: [AdaScriptSource]) throws { + if unsafe RenderEngine.shared == nil { + // CLI exports decode textured components without starting Studio's render world. + let previousBackend = unsafe RenderEngine.configurations.preferredBackend + defer { unsafe RenderEngine.configurations.preferredBackend = previousBackend } + unsafe RenderEngine.configurations.preferredBackend = .headless + RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "Native scene export"))) + } EditorComponentRegistry.registerBuiltIns() let schemas = try AdaScriptSchemaParser.parse(sources: sources).filter { $0.kind == .component } let nativeNames = Set(schemas.flatMap { [$0.name, $0.id] + $0.aliases }) @@ -431,7 +438,26 @@ actor EditorAdaScriptNativeExporter { .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", exact: "0.59.0") ], targets: [ \(nativeTarget) - .\(targetKind)(name: "AdaNativeGame", dependencies: [\(nativeDependency).product(name: "AdaEngine", package: "AdaEngine"), .product(name: "AdaMultiplayer", package: "AdaEngine"), .product(name: "GravityAOT", package: "gravity-lang"), .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi]))], resources: [.copy("GameAssets")], linkerSettings: [.unsafeFlags(["-Xclang-linker", "-mexec-model=reactor", "-Xlinker", "--export-if-defined=main", "-Xlinker", "--export-if-defined=__main_argc_argv"], .when(platforms: [.wasi])), .unsafeFlags(["-Xlinker", "--strip-debug"], .when(platforms: [.wasi], configuration: .release))]) + .\(targetKind)( + name: "AdaNativeGame", + dependencies: [ + \(nativeDependency).product(name: "AdaEngine", package: "AdaEngine"), + .product(name: "AdaMultiplayer", package: "AdaEngine"), + .product(name: "GravityAOT", package: "gravity-lang"), + .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])) + ], + resources: [.copy("GameAssets")], + linkerSettings: [ + .unsafeFlags([ + "-Xclang-linker", "-mexec-model=reactor", + "-Xlinker", "--export-if-defined=main", + "-Xlinker", "--export-if-defined=__main_argc_argv" + ], .when(platforms: [.wasi])), + // Match AdaWebPlayer's debug stack budget during renderer startup. + .unsafeFlags(["-Xlinker", "-z", "-Xlinker", "stack-size=4194304"], .when(platforms: [.wasi], configuration: .debug)), + .unsafeFlags(["-Xlinker", "--strip-debug"], .when(platforms: [.wasi], configuration: .release)) + ] + ) ]) """ } diff --git a/Editor/Sources/AdaEditor/Tooling/EditorAndroidTools.swift b/Editor/Sources/AdaEditor/Tooling/EditorAndroidTools.swift index bc867d597..d770f4eec 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorAndroidTools.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorAndroidTools.swift @@ -53,6 +53,11 @@ struct EditorAndroidConfiguration: Equatable, Sendable { if !value.isEmpty { values[key] = value } } for (key, value) in preferences ?? [:] where !value.isEmpty { values[key] = value } + if environment["ADAENGINE_SAGA_NATIVE"] == "1" { + values["ADAENGINE_SAGA_NATIVE"] = "1" + values["ADAENGINE_SAGA_PACKAGE_PATH"] = environment["ADAENGINE_SAGA_PACKAGE_PATH"] + ?? engineRoot?.deletingLastPathComponent().appendingPathComponent("SAGA").path + } #if os(iOS) || os(tvOS) || os(visionOS) let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) #else @@ -142,7 +147,7 @@ struct EditorAndroidConfiguration: Equatable, Sendable { #else let required = [ "SWIFT_ANDROID_SWIFT", "SWIFT_ANDROID_SDKS_PATH", "ANDROID_HOME", "ANDROID_NDK_HOME", - "ADAENGINE_SWAN_PACKAGE_PATH", + environment["ADAENGINE_SAGA_NATIVE"] == "1" ? "ADAENGINE_SAGA_PACKAGE_PATH" : "ADAENGINE_SWAN_PACKAGE_PATH", ] #endif for key in required { diff --git a/Editor/Sources/AdaEditor/Tooling/EditorBuildExportConfiguration.swift b/Editor/Sources/AdaEditor/Tooling/EditorBuildExportConfiguration.swift index 26042c8ca..2465cd839 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorBuildExportConfiguration.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorBuildExportConfiguration.swift @@ -76,7 +76,7 @@ enum EditorBuildPlatform: String, CaseIterable, Hashable, Sendable { return [ EditorBuildToolField("ADA_WEB_SWIFT_EXECUTABLE", "Swift WASM executable", .executable), EditorBuildToolField("ADA_WEB_SWIFT_SDK", "Swift WASM SDK identifier", .identifier), - EditorBuildToolField("TINT_EXECUTABLE", "Tint shader compiler", .executable) + EditorBuildToolField("ADAENGINE_SAGA_PACKAGE_PATH", "SAGA", .directory) ] case .macOS: return [swift] + apple + Array(native.dropFirst()) case .iOS, .xr: return [swift] + apple diff --git a/Editor/Sources/AdaEditor/Tooling/EditorBuildToolDiscovery.swift b/Editor/Sources/AdaEditor/Tooling/EditorBuildToolDiscovery.swift index 31222def1..c5f10529a 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorBuildToolDiscovery.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorBuildToolDiscovery.swift @@ -68,7 +68,7 @@ actor EditorBuildToolDiscovery { found["SWIFT_EXECUTABLE"] = first("SWIFT_EXECUTABLE", [value("SWIFT_EXECUTABLE")].compactMap { $0 } + executables("swift") + swiftPaths) found["CMAKE_EXECUTABLE"] = first("CMAKE_EXECUTABLE", [value("CMAKE_EXECUTABLE")].compactMap { $0 } + executables("cmake") + cmakePaths) found["NINJA_EXECUTABLE"] = first("NINJA_EXECUTABLE", [value("NINJA_EXECUTABLE")].compactMap { $0 } + executables("ninja") + ninjaPaths) - found["TINT_EXECUTABLE"] = first("TINT_EXECUTABLE", [value("TINT_EXECUTABLE")].compactMap { $0 } + executables("tint")) + found["ADAENGINE_SAGA_PACKAGE_PATH"] = first("ADAENGINE_SAGA_PACKAGE_PATH", [value("ADAENGINE_SAGA_PACKAGE_PATH")].compactMap { $0 }) found["ANDROID_HOME"] = first("ANDROID_HOME", [value("ANDROID_HOME"), value("ANDROID_SDK_ROOT")].compactMap { $0 } + androidPaths) found["JAVA_HOME"] = first("JAVA_HOME", [value("JAVA_HOME")].compactMap { $0 } + javaPaths + executables("java").map { url($0).deletingLastPathComponent().deletingLastPathComponent().path @@ -87,7 +87,7 @@ actor EditorBuildToolDiscovery { found["VCToolsInstallDir"] = first("VCToolsInstallDir", [value("VCToolsInstallDir")].compactMap { $0 } + visualStudios.flatMap { children($0.appendingPathComponent("VC/Tools/MSVC")).map(\.path) }) // The Windows exporter owns a Linux toolchain inside WSL. Never fill its - // compiler fields with native Windows Swift, SDK or Tint paths. + // compiler fields with native Windows Swift or SDK paths. #else found["CC"] = first("CC", [value("CC")].compactMap { $0 } + executables("clang")) found["ADA_WEB_SWIFT_EXECUTABLE"] = first("ADA_WEB_SWIFT_EXECUTABLE", [value("ADA_WEB_SWIFT_EXECUTABLE")].compactMap { $0 } + swiftPaths.filter { $0.contains("6.3.2") }) @@ -115,7 +115,8 @@ actor EditorBuildToolDiscovery { case "ANDROID_NDK_HOME": return directory.boolValue && fileManager.fileExists(atPath: root.appendingPathComponent("source.properties").path) case "WindowsSdkDir": return directory.boolValue && fileManager.fileExists(atPath: root.appendingPathComponent("Include").path) case "VCToolsInstallDir": return directory.boolValue && fileManager.fileExists(atPath: root.appendingPathComponent("include").path) - case "SWIFT_EXECUTABLE", "CMAKE_EXECUTABLE", "NINJA_EXECUTABLE", "TINT_EXECUTABLE", "CC", "SWIFT_ANDROID_SWIFT", "ADA_WEB_SWIFT_EXECUTABLE", "GRADLE_EXECUTABLE": + case "ADAENGINE_SAGA_PACKAGE_PATH": return directory.boolValue && fileManager.fileExists(atPath: root.appendingPathComponent("Package.swift").path) + case "SWIFT_EXECUTABLE", "CMAKE_EXECUTABLE", "NINJA_EXECUTABLE", "CC", "SWIFT_ANDROID_SWIFT", "ADA_WEB_SWIFT_EXECUTABLE", "GRADLE_EXECUTABLE": return !directory.boolValue && fileManager.isExecutableFile(atPath: path) default: return directory.boolValue } diff --git a/Editor/Sources/AdaEditor/Tooling/EditorWindowsBuildWorker.swift b/Editor/Sources/AdaEditor/Tooling/EditorWindowsBuildWorker.swift index 9a226622f..f864b5129 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorWindowsBuildWorker.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorWindowsBuildWorker.swift @@ -24,8 +24,9 @@ enum EditorWindowsBuildWorker { if let scratch { arguments += ["--scratch", linuxPath(scratch.path)] } if serve { arguments.append("--serve") } if let gravityRoot { arguments += ["--gravity", linuxPath(gravityRoot.path)] } - if let tint = environment["TINT_EXECUTABLE"], !tint.isEmpty { - arguments += ["--tint", linuxPath(tint)] + if let saga = environment["ADAENGINE_SAGA_PACKAGE_PATH"] ?? ProcessInfo.processInfo.environment["ADAENGINE_SAGA_PACKAGE_PATH"], + !saga.isEmpty { + arguments += ["--saga", linuxPath(saga)] } return command(arguments, package: package, engineRoot: engineRoot) } @@ -43,7 +44,11 @@ enum EditorWindowsBuildWorker { if let gravity = ProcessInfo.processInfo.environment["ADAENGINE_GRAVITY_PACKAGE_PATH"] { arguments += ["--gravity", linuxPath(gravity)] } - if let swan = configuration.environment["ADAENGINE_SWAN_PACKAGE_PATH"] { + if configuration.environment["ADAENGINE_SAGA_NATIVE"] == "1" { + let saga = configuration.environment["ADAENGINE_SAGA_PACKAGE_PATH"] + ?? engineRoot.deletingLastPathComponent().appendingPathComponent("SAGA").path + arguments += ["--saga", linuxPath(saga)] + } else if let swan = configuration.environment["ADAENGINE_SWAN_PACKAGE_PATH"] { arguments += ["--swan", linuxPath(swan)] } return command(arguments, package: package, engineRoot: engineRoot) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAgentActivityBackground.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAgentActivityBackground.swift index fd6c58378..315ee1b62 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorAgentActivityBackground.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAgentActivityBackground.swift @@ -258,6 +258,13 @@ struct EditorAgentGlowMaterial: UIShaderMaterial { parameters = EditorAgentGlowParameters(color: .clear, geometry: Vector4(1, 1, 0, 42), style: Vector4(0, 0.065, 14, 1)) } + static func shaderModule() throws -> ShaderModule? { + guard ShaderModule.supportsBundledWESL, + let url = Bundle.editor.url(forResource: "agent_activity", withExtension: "wesl", subdirectory: "Assets/Shaders") + else { return nil } + return try ShaderModule.create(from: url) + } + static func fragmentShader() throws -> AssetHandle { guard let url = Bundle.editor.url(forResource: "agent_activity", withExtension: "glsl", subdirectory: "Assets/Shaders") else { throw EditorAgentGlowResourceError.missingShader diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorVoiceOrb.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorVoiceOrb.swift index 06bb6210c..9f79a73ce 100644 --- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorVoiceOrb.swift +++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorVoiceOrb.swift @@ -113,6 +113,13 @@ struct MobileVoiceOrbMaterial: UIShaderMaterial { parameters = MobileVoiceOrbParameters(geometry: Vector4(1, 1, 8, 0), style: Vector4(1, 0, 0, 0)) } + static func shaderModule() throws -> ShaderModule? { + guard ShaderModule.supportsBundledWESL, + let url = Bundle.editor.url(forResource: "mobile_voice_orb", withExtension: "wesl", subdirectory: "Assets/Shaders") + else { return nil } + return try ShaderModule.create(from: url) + } + static func fragmentShader() throws -> AssetHandle { guard let url = Bundle.editor.url(forResource: "mobile_voice_orb", withExtension: "glsl", subdirectory: "Assets/Shaders") else { throw CocoaError(.fileNoSuchFile) diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift index 7e790e7d8..3cd67a05b 100644 --- a/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift @@ -1,4 +1,4 @@ -#if canImport(CoreFoundation) +#if canImport(CoreFoundation) && !os(WASI) import CoreFoundation #endif import Foundation @@ -59,7 +59,7 @@ public enum StudioValue: Codable, Equatable, Sendable { if value is NSNull { return .null } if let number = value as? NSNumber { let kind = unsafe String(cString: number.objCType) - #if canImport(CoreFoundation) + #if canImport(CoreFoundation) && !os(WASI) if CFGetTypeID(number) == CFBooleanGetTypeID() { return .bool(number.boolValue) } #else if kind == "c" { return .bool(number.boolValue) } diff --git a/Editor/Tests/AdaEditorTests/EditorBuildExportSettingsTests.swift b/Editor/Tests/AdaEditorTests/EditorBuildExportSettingsTests.swift index 4c6cce5c8..87131922e 100644 --- a/Editor/Tests/AdaEditorTests/EditorBuildExportSettingsTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorBuildExportSettingsTests.swift @@ -88,7 +88,7 @@ struct EditorBuildExportSettingsTests { settings.values[EditorBuildPlatform.web.rawValue] = [ "ADA_WEB_SWIFT_EXECUTABLE": "/Tools/WASM/swift", "ADA_WEB_SWIFT_SDK": "custom-wasm-sdk", - "TINT_EXECUTABLE": "/Tools/Tint/tint" + "ADAENGINE_SAGA_PACKAGE_PATH": "/Tools/SAGA" ] let service = SwiftPMWorkspaceService() let root = URL(fileURLWithPath: "/tmp/Game") @@ -100,11 +100,11 @@ struct EditorBuildExportSettingsTests { #expect(web.executablePath.lowercased().hasSuffix("wsl.exe")) #expect(web.arguments.contains("/Tools/WASM/swift")) #expect(web.arguments.contains("custom-wasm-sdk")) - #expect(web.arguments.contains("/Tools/Tint/tint")) + #expect(web.arguments.contains("/Tools/SAGA")) #else #expect(web.executablePath == "/Tools/WASM/swift") #expect(web.arguments.suffix(2) == ["--swift-sdk", "custom-wasm-sdk"]) - #expect(web.environment["TINT_EXECUTABLE"] == "/Tools/Tint/tint") + #expect(web.environment["ADAENGINE_SAGA_PACKAGE_PATH"] == "/Tools/SAGA") #expect(web.environment["BUILD_WASM"] == "1") #endif #expect(web.environment["DEVELOPER_DIR"] == nil) diff --git a/Editor/Tests/AdaEditorTests/EditorBuildToolDiscoveryTests.swift b/Editor/Tests/AdaEditorTests/EditorBuildToolDiscoveryTests.swift index 9166b4526..1345f6392 100644 --- a/Editor/Tests/AdaEditorTests/EditorBuildToolDiscoveryTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorBuildToolDiscoveryTests.swift @@ -69,7 +69,7 @@ struct EditorBuildToolDiscoveryTests { defer { fixture.remove() } let tool = try fixture.executable("Tools/swift") var configuration = EditorBuildExportConfiguration() - let found = ["ADA_WEB_SWIFT_EXECUTABLE": tool.path, "TINT_EXECUTABLE": tool.path] + let found = ["ADA_WEB_SWIFT_EXECUTABLE": tool.path, "ADAENGINE_SAGA_PACKAGE_PATH": tool.path] #expect(configuration.applyDiscoveredTools(found, for: .web, unchangedSince: configuration) == 0) #expect(configuration.values.isEmpty) } diff --git a/Editor/WebUI/pnpm-lock.yaml b/Editor/WebUI/pnpm-lock.yaml index 7a07e24c2..b7af515bb 100644 --- a/Editor/WebUI/pnpm-lock.yaml +++ b/Editor/WebUI/pnpm-lock.yaml @@ -55,193 +55,193 @@ importers: packages: '@codemirror/commands@6.11.1': - resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==, tarball: https://npm.yandex-team.ru/@codemirror%2fcommands/-/commands-6.11.1.tgz} + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} '@codemirror/language@6.13.1': - resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==, tarball: https://npm.yandex-team.ru/@codemirror%2flanguage/-/language-6.13.1.tgz} + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} '@codemirror/search@6.7.2': - resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==, tarball: https://npm.yandex-team.ru/@codemirror%2fsearch/-/search-6.7.2.tgz} + resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} '@codemirror/state@6.7.6': - resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==, tarball: https://npm.yandex-team.ru/@codemirror%2fstate/-/state-6.7.6.tgz} + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} '@codemirror/streamparser@6.0.0': - resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==, tarball: https://npm.yandex-team.ru/@codemirror%2fstreamparser/-/streamparser-6.0.0.tgz} + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} '@codemirror/view@6.43.14': - resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==, tarball: https://npm.yandex-team.ru/@codemirror%2fview/-/view-6.43.14.tgz} + resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==} '@esbuild/aix-ppc64@0.25.5': - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==, tarball: https://npm.yandex-team.ru/@esbuild%2faix-ppc64/-/aix-ppc64-0.25.5.tgz} + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] '@esbuild/android-arm64@0.25.5': - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==, tarball: https://npm.yandex-team.ru/@esbuild%2fandroid-arm64/-/android-arm64-0.25.5.tgz} + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} engines: {node: '>=18'} cpu: [arm64] os: [android] '@esbuild/android-arm@0.25.5': - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==, tarball: https://npm.yandex-team.ru/@esbuild%2fandroid-arm/-/android-arm-0.25.5.tgz} + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} engines: {node: '>=18'} cpu: [arm] os: [android] '@esbuild/android-x64@0.25.5': - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==, tarball: https://npm.yandex-team.ru/@esbuild%2fandroid-x64/-/android-x64-0.25.5.tgz} + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} engines: {node: '>=18'} cpu: [x64] os: [android] '@esbuild/darwin-arm64@0.25.5': - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fdarwin-arm64/-/darwin-arm64-0.25.5.tgz} + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.25.5': - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fdarwin-x64/-/darwin-x64-0.25.5.tgz} + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] '@esbuild/freebsd-arm64@0.25.5': - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==, tarball: https://npm.yandex-team.ru/@esbuild%2ffreebsd-arm64/-/freebsd-arm64-0.25.5.tgz} + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.25.5': - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==, tarball: https://npm.yandex-team.ru/@esbuild%2ffreebsd-x64/-/freebsd-x64-0.25.5.tgz} + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] '@esbuild/linux-arm64@0.25.5': - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-arm64/-/linux-arm64-0.25.5.tgz} + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.25.5': - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-arm/-/linux-arm-0.25.5.tgz} + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.25.5': - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-ia32/-/linux-ia32-0.25.5.tgz} + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.25.5': - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-loong64/-/linux-loong64-0.25.5.tgz} + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.25.5': - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-mips64el/-/linux-mips64el-0.25.5.tgz} + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.25.5': - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-ppc64/-/linux-ppc64-0.25.5.tgz} + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.25.5': - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-riscv64/-/linux-riscv64-0.25.5.tgz} + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.25.5': - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-s390x/-/linux-s390x-0.25.5.tgz} + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} engines: {node: '>=18'} cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.25.5': - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-x64/-/linux-x64-0.25.5.tgz} + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} engines: {node: '>=18'} cpu: [x64] os: [linux] '@esbuild/netbsd-arm64@0.25.5': - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==, tarball: https://npm.yandex-team.ru/@esbuild%2fnetbsd-arm64/-/netbsd-arm64-0.25.5.tgz} + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.25.5': - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fnetbsd-x64/-/netbsd-x64-0.25.5.tgz} + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] '@esbuild/openbsd-arm64@0.25.5': - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==, tarball: https://npm.yandex-team.ru/@esbuild%2fopenbsd-arm64/-/openbsd-arm64-0.25.5.tgz} + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.25.5': - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==, tarball: https://npm.yandex-team.ru/@esbuild%2fopenbsd-x64/-/openbsd-x64-0.25.5.tgz} + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] '@esbuild/sunos-x64@0.25.5': - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==, tarball: https://npm.yandex-team.ru/@esbuild%2fsunos-x64/-/sunos-x64-0.25.5.tgz} + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] '@esbuild/win32-arm64@0.25.5': - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==, tarball: https://npm.yandex-team.ru/@esbuild%2fwin32-arm64/-/win32-arm64-0.25.5.tgz} + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} engines: {node: '>=18'} cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.25.5': - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fwin32-ia32/-/win32-ia32-0.25.5.tgz} + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.25.5': - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==, tarball: https://npm.yandex-team.ru/@esbuild%2fwin32-x64/-/win32-x64-0.25.5.tgz} + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} engines: {node: '>=18'} cpu: [x64] os: [win32] '@lezer/common@1.5.3': - resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==, tarball: https://npm.yandex-team.ru/@lezer%2fcommon/-/common-1.5.3.tgz} + resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==} '@lezer/highlight@1.2.5': - resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==, tarball: https://npm.yandex-team.ru/@lezer%2fhighlight/-/highlight-1.2.5.tgz} + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} '@lezer/lr@1.4.11': - resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==, tarball: https://npm.yandex-team.ru/@lezer%2flr/-/lr-1.4.11.tgz} + resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==} '@marijn/find-cluster-break@1.0.4': - resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==, tarball: https://npm.yandex-team.ru/@marijn%2ffind-cluster-break/-/find-cluster-break-1.0.4.tgz} + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} '@types/node@26.6.5': - resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==, tarball: https://npm.yandex-team.ru/@types%2fnode/-/node-26.6.5.tgz} + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/whatwg-mimetype@3.0.2': - resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==, tarball: https://npm.yandex-team.ru/@types%2fwhatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz} + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} '@types/ws@8.18.2': - resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==, tarball: https://npm.yandex-team.ru/@types%2fws/-/ws-8.18.2.tgz} + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} buffer-image-size@0.6.4: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} diff --git a/Editor/project.yml b/Editor/project.yml index 28fa1f14f..231a2f26c 100644 --- a/Editor/project.yml +++ b/Editor/project.yml @@ -83,6 +83,7 @@ targets: basedOnDependencyAnalysis: false inputFiles: - $(BUILT_PRODUCTS_DIR)/AdaEditor + - $(SRCROOT)/scripts/relocate-macos-executable.sh - $(BUILT_PRODUCTS_DIR)/AdaEditor_AdaEditor.bundle - $(BUILT_PRODUCTS_DIR)/AdaEngine_AdaUI.bundle - $(BUILT_PRODUCTS_DIR)/AdaEngine_AdaA2UI.bundle @@ -121,6 +122,7 @@ targets: mkdir -p "${destination_binary:h}" /usr/bin/ditto "${source_binary}" "${destination_binary}" /bin/chmod +x "${destination_binary}" + /bin/bash "${SRCROOT}/scripts/relocate-macos-executable.sh" "${destination_binary}" "${BUILT_PRODUCTS_DIR}" mkdir -p "${destination_resources:h}" /usr/bin/ditto "${source_resources}" "${destination_resources}" /usr/bin/ditto "${source_ui_resources}" "${destination_ui_resources}" @@ -167,6 +169,7 @@ targets: basedOnDependencyAnalysis: false inputFiles: - $(BUILT_PRODUCTS_DIR)/AdaEditor + - $(SRCROOT)/scripts/relocate-macos-executable.sh - $(BUILT_PRODUCTS_DIR)/AdaEditor_AdaEditor.bundle - $(BUILT_PRODUCTS_DIR)/AdaEngine_AdaUI.bundle - $(BUILT_PRODUCTS_DIR)/AdaEngine_AdaA2UI.bundle @@ -207,13 +210,14 @@ targets: mkdir -p "${destination_binary:h}" /usr/bin/ditto "${source_binary}" "${destination_binary}" /bin/chmod +x "${destination_binary}" + /bin/bash "${SRCROOT}/scripts/relocate-macos-executable.sh" "${destination_binary}" "${BUILT_PRODUCTS_DIR}" mkdir -p "${destination_resources:h}" /usr/bin/ditto "${source_resources}" "${destination_resources}" /usr/bin/ditto "${source_ui_resources}" "${destination_ui_resources}" /usr/bin/ditto "${source_a2ui_resources}" "${destination_a2ui_resources}" /usr/bin/ditto "${source_spine_resources}" "${destination_spine_resources}" cli_binary="${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/MacOS/adastudio" - /usr/bin/ditto "${source_binary}" "${cli_binary}" + /usr/bin/ditto "${destination_binary}" "${cli_binary}" /bin/chmod +x "${cli_binary}" /usr/bin/python3 "${SRCROOT}/scripts/stage-build-sdk.py" \ --engine-root "${SRCROOT}/.." \ diff --git a/Editor/scripts/relocate-macos-executable.sh b/Editor/scripts/relocate-macos-executable.sh new file mode 100644 index 000000000..08ded1a33 --- /dev/null +++ b/Editor/scripts/relocate-macos-executable.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Replace SwiftPM's build-directory library lookup in the packaged executable. +set -euo pipefail + +binary="${1:?Expected a staged executable}" +build_rpath="${2:?Expected the build products directory}/PackageFrameworks" +bundle_rpath='@executable_path/../Frameworks' + +rpaths="$(/usr/bin/otool -l "$binary" | /usr/bin/awk ' + $1 == "cmd" { is_rpath = ($2 == "LC_RPATH") } + is_rpath && $1 == "path" { + sub(/^[[:space:]]*path[[:space:]]+/, "") + sub(/[[:space:]]+\(offset [0-9]+\)$/, "") + print + is_rpath = 0 + } +')" + +if /usr/bin/grep -Fqx "$build_rpath" <<< "$rpaths"; then + if /usr/bin/grep -Fqx "$bundle_rpath" <<< "$rpaths"; then + /usr/bin/install_name_tool -delete_rpath "$build_rpath" "$binary" + else + /usr/bin/install_name_tool -rpath "$build_rpath" "$bundle_rpath" "$binary" + fi +fi diff --git a/Editor/scripts/stage-build-sdk.py b/Editor/scripts/stage-build-sdk.py index 672c99dc1..8e4bb5399 100644 --- a/Editor/scripts/stage-build-sdk.py +++ b/Editor/scripts/stage-build-sdk.py @@ -40,12 +40,18 @@ def copy_inputs(root, destination, names): def stage(engine, compiler, destination, swan=None): + saga_override = os.environ.get("ADAENGINE_SAGA_PACKAGE_PATH") + saga = Path(saga_override).resolve() if saga_override else engine.resolve().parent / "SAGA" + if saga_override and not (saga / "Package.swift").is_file(): + raise RuntimeError(f"Local SAGA checkout is missing Package.swift: {saga}") destination.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".ada-sdk-", dir=destination.parent) as temporary: staged = Path(temporary) / "BuildSDK" staged.mkdir() copy_inputs(engine, staged / "AdaEngine", ["Package.swift", "Package.resolved", ".swiftlint.yml", "LICENSE", "NOTICE", "Sources", "Plugins", "Tools", "Tests", "Assets", "script", "Editor/StudioCore"]) + if (saga / "Package.swift").is_file(): + copy_inputs(saga, staged / "SAGA", ["Package.swift", "LICENSE", "NOTICE", "Sources", "Tests", "web"]) # These checked-in browser modules are required by the Web export plugin. # Ordinary generated dist directories remain excluded from the SDK. browser_runtime = Path("Plugins/AdaWebExportPlugin/BrowserWASIShim/dist") diff --git a/Editor/scripts/test_stage_build_sdk.py b/Editor/scripts/test_stage_build_sdk.py index fb4f0d02f..ea10ce1ff 100644 --- a/Editor/scripts/test_stage_build_sdk.py +++ b/Editor/scripts/test_stage_build_sdk.py @@ -2,6 +2,7 @@ import importlib.util import json +import os from pathlib import Path import tempfile import unittest @@ -44,28 +45,39 @@ def test_source_sdk_is_self_contained_and_omits_private_build_state(self): root = Path(temporary) engine = root / "engine" compiler = root / "compiler" + saga = root / "SAGA" for base, files in [(engine, ["Package.swift", "LICENSE", "Sources/Runtime.swift", "Sources/.build/private", ".git/config", "script/ensure_tint.py", "Demos/Example/binary.glb"]), + (saga, ["Package.swift", "Sources/SAGACompiler/Compiler.swift", "Sources/.build/private", ".git/config", + "web/webgpu.mjs", "web/golden.mjs", "web/node_modules/private"]), (compiler, ["Package.swift", "LICENSE", "src/shared/header.h", "tools/aot_build.py", "binding/Runtime.swift"])]: for name in files: path = base / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(name) - host = compiler / "gravity" + host = compiler / ("gravity.exe" if os.name == "nt" else "gravity") host.write_text("#!/bin/sh\nexit 0\n") host.chmod(0o755) destination = root / "Moved Studio.app/Contents/Resources/BuildSDK" - sdk.stage(engine, compiler, destination) + with patch.object(sdk.subprocess, "run"): + sdk.stage(engine, compiler, destination) self.assertEqual(json.loads((destination / "sdk.json").read_text())["schemaVersion"], 1) self.assertTrue((destination / "AdaEngine/Sources/Runtime.swift").is_file()) self.assertTrue((destination / "AdaEngine/script/ensure_tint.py").is_file()) - self.assertTrue((destination / "AdaScript/gravity").is_file()) + self.assertTrue((destination / "AdaScript" / host.name).is_file()) self.assertTrue((destination / "AdaScript/LICENSE").is_file()) + self.assertTrue((destination / "SAGA/Package.swift").is_file()) + self.assertTrue((destination / "SAGA/Sources/SAGACompiler/Compiler.swift").is_file()) + self.assertFalse((destination / "SAGA/Sources/.build").exists()) + self.assertTrue((destination / "SAGA/web/webgpu.mjs").is_file(), "The staged SDK must contain the SAGA WebGPU host") + self.assertTrue((destination / "SAGA/web/golden.mjs").is_file()) + self.assertFalse((destination / "SAGA/web/node_modules").exists()) self.assertFalse((destination / "AdaEngine/.git").exists()) self.assertFalse((destination / "AdaEngine/Demos").exists()) self.assertFalse((destination / "AdaEngine/Sources/.build").exists()) self.assertFalse((engine / "sdk.json").exists()) # A second stage replaces the complete SDK without accumulating nested copies. - sdk.stage(engine, compiler, destination) + with patch.object(sdk.subprocess, "run"): + sdk.stage(engine, compiler, destination) self.assertFalse((destination / "BuildSDK").exists()) def test_sdk_includes_local_studio_core_package(self): @@ -126,11 +138,12 @@ def test_android_sdk_keeps_dawn_static_library_after_relocation(self): path=base/name path.parent.mkdir(parents=True,exist_ok=True) path.write_text("fixture") - host=compiler/"gravity" + host=compiler/("gravity.exe" if os.name == "nt" else "gravity") host.write_text("#!/bin/sh\nexit 0\n") host.chmod(0o755) destination=root/"Studio.app/Contents/Resources/BuildSDK" - sdk.stage(engine,compiler,destination,swan) + with patch.object(sdk.subprocess, "run"): + sdk.stage(engine,compiler,destination,swan) moved=root/"Relocated/BuildSDK" moved.parent.mkdir() destination.rename(moved) diff --git a/Package.resolved b/Package.resolved index 780688c1c..97ac7217f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,21 +1,21 @@ { - "originHash" : "c981a41efd56a081537f4f7b35a4767b79479c5b3a8e43af7e82d33039e450f2", + "originHash" : "72f5945e6b7791063c3f024ee00cf8ac9c73053598433b2246e931725fa45f89", "pins" : [ { - "identity" : "swan", + "identity" : "javascriptkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/AdaEngine/swan", + "location" : "https://github.com/swiftwasm/JavaScriptKit.git", "state" : { - "revision" : "790df787281e9cf1c2358a11bb42b2ec2df01029" + "revision" : "991755e698a7516eac571e6f6a65c04c907ce5ed", + "version" : "0.59.0" } }, { - "identity" : "swift-argument-parser", + "identity" : "saga", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", + "location" : "https://github.com/AdaEngine/SAGA.git", "state" : { - "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", - "version" : "1.8.2" + "revision" : "8be633b5be0f747e0aa4e030fcad03c4ca17908f" } }, { @@ -72,15 +72,6 @@ "version" : "1.0.0" } }, - { - "identity" : "swift-format", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-format.git", - "state" : { - "revision" : "d54c5be7afba3e5f52ae29e2371e444a3c2a49c1", - "version" : "603.0.0" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -117,15 +108,6 @@ "version" : "1.3.0" } }, - { - "identity" : "swift-subprocess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-subprocess.git", - "state" : { - "branch" : "0.2.1", - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -135,15 +117,6 @@ "version" : "603.0.2" } }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" - } - }, { "identity" : "swiftlintplugins", "kind" : "remoteSourceControl", @@ -173,4 +146,4 @@ } ], "version" : 3 -} \ No newline at end of file +} diff --git a/Package.swift b/Package.swift index 9e751efc4..a8f995cdf 100644 --- a/Package.swift +++ b/Package.swift @@ -9,15 +9,11 @@ import PackageDescription import AppleProductTypes #endif -let isHeadlessCIEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - let headlessValue = environment["ADAENGINE_HEADLESS"]?.lowercased() - let disableSwanValue = environment["ADAENGINE_DISABLE_SWAN"]?.lowercased() - let enabledValues: Set = ["1", "true", "yes", "on"] - - return headlessValue.map(enabledValues.contains) == true - || disableSwanValue.map(enabledValues.contains) == true -}() +let enabledEnvironmentValues: Set = ["1", "true", "yes", "on"] +let isHeadlessCIEnabled = ProcessInfo.processInfo.environment["ADAENGINE_HEADLESS"] + .map { enabledEnvironmentValues.contains($0.lowercased()) } == true +let isSwanDisabled = ProcessInfo.processInfo.environment["ADAENGINE_DISABLE_SWAN"] + .map { enabledEnvironmentValues.contains($0.lowercased()) } == true let isAndroidBuildEnabled = ProcessInfo.processInfo.environment["ADAENGINE_ANDROID"] == "1" @@ -29,11 +25,19 @@ let isECSBenchmarkEnabled = false let isWebExportEnabled: Bool = { let webExportValue = ProcessInfo.processInfo.environment["ADAENGINE_WEB_EXPORT"]?.lowercased() - let enabledValues: Set = ["1", "true", "yes", "on"] - - return webExportValue.map(enabledValues.contains) == true + return webExportValue.map(enabledEnvironmentValues.contains) == true }() +let isSAGANativeEnabled = ProcessInfo.processInfo.environment["ADAENGINE_SAGA_NATIVE"] == "1" && !isWebExportEnabled +#if os(macOS) +let sagaMetalOverride = ProcessInfo.processInfo.environment["ADAENGINE_SAGA_METAL"] +let isSAGAMetalEnabled = !isWebExportEnabled && !isAndroidBuildEnabled && !isSAGANativeEnabled + && (sagaMetalOverride.map { $0 == "1" } ?? !isHeadlessCIEnabled) +#else +let isSAGAMetalEnabled = false +#endif +let usesSwan = !isHeadlessCIEnabled && !isSwanDisabled && !isWebExportEnabled && !isSAGANativeEnabled && !isSAGAMetalEnabled + // Local compiler development can override the checked-in, patched AdaScript runtime. let gravityDevelopmentPath = ProcessInfo.processInfo.environment["ADAENGINE_GRAVITY_PACKAGE_PATH"] let gravityAOTDependencies: [Target.Dependency] = [ @@ -44,11 +48,7 @@ let gravityAOTDependencies: [Target.Dependency] = [ import Darwin.C /// Only xcode can import AppleProductTypes and we can use it as checker -#if canImport(AppleProductTypes) -let isWGPUEnabled = isWebExportEnabled || isAndroidBuildEnabled // We can't build wgpu from xcode, except web export builds. -#else -let isWGPUEnabled = isWebExportEnabled || isAndroidBuildEnabled -#endif +let isWGPUEnabled = isWebExportEnabled || (isAndroidBuildEnabled && usesSwan) #else @@ -60,13 +60,15 @@ import Glibc import WinSDK #endif -let isWGPUEnabled = !isHeadlessCIEnabled +let isWGPUEnabled = isWebExportEnabled || usesSwan #endif extension String { static let wgpuTrait = "WGPU_ENABLED" } +let usesSAGACompiler = (usesSwan && isWGPUEnabled && !isAndroidBuildEnabled) || isWebExportEnabled + let applePlatforms: [Platform] = [.iOS, .macOS, .tvOS, .watchOS, .visionOS] var products: [Product] = [ @@ -216,7 +218,9 @@ let wasmExecutableLinkerSettings: [LinkerSetting] = [ "--export-if-defined=main", "-Xlinker", "--export-if-defined=__main_argc_argv" - ], .when(platforms: [.wasi])) + ], .when(platforms: [.wasi])), + // Debug builds use more stack while constructing the renderer and its shader pipelines. + .unsafeFlags(["-Xlinker", "-z", "-Xlinker", "stack-size=4194304"], .when(platforms: [.wasi], configuration: .debug)) ] // MARK: Ada Engine SDK @@ -254,7 +258,7 @@ var adaEngineDependencies: [Target.Dependency] = [ // the renderer; this target shares its selected D3D12 device and textures. let dlssBuildConfiguration: [String: Any]? = { #if os(Windows) - guard !isHeadlessCIEnabled, ProcessInfo.processInfo.environment["ADAENGINE_DISABLE_DLSS"] != "1" else { + guard usesSwan, ProcessInfo.processInfo.environment["ADAENGINE_DISABLE_DLSS"] != "1" else { return nil } let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() @@ -283,17 +287,6 @@ var adaRenderDependencies: [Target.Dependency] = [ "SPIRVCompiler", "libpng", "CImageDecoder", - .product( - name: "Subprocess", - package: "swift-subprocess", - condition: .when(platforms: [ - .macOS, - .linux, - .windows - ], traits: [ - .wgpuTrait - ]) - ), .product( name: "JavaScriptKit", package: "JavaScriptKit", @@ -301,17 +294,53 @@ var adaRenderDependencies: [Target.Dependency] = [ ), ] -if !isHeadlessCIEnabled { +if usesSAGACompiler { + adaRenderDependencies.append( + .target( + name: "AdaWGSLCompiler", + condition: .when(platforms: [.macOS, .linux, .windows]) + ) + ) +} +if usesSwan { adaRenderDependencies.append( .product( name: "WebGPU", package: "swan", - condition: .when(traits: [ + // Browser builds render through SAGA; Swan only backs the native Dawn path. + condition: .when(platforms: applePlatforms + [.linux, .windows, .android], traits: [ .wgpuTrait ]) ) ) } +if isWebExportEnabled { + // Browser rendering runs through SAGA (see Documentation/Designs/saga-migration.md). + adaRenderDependencies.append(.product(name: "SAGAWebHost", package: "SAGA", condition: .when(platforms: [.wasi]))) + adaRenderDependencies.append(.product(name: "SAGAWebGPU", package: "SAGA", condition: .when(platforms: [.wasi]))) + adaRenderDependencies.append(.product(name: "SAGA", package: "SAGA", condition: .when(platforms: [.wasi]))) + adaRenderDependencies.append(.product(name: "SAGACompiler", package: "SAGA", condition: .when(platforms: [.wasi]))) + swiftSettings.append(.define("SAGA_WEBGPU", .when(platforms: [.wasi]))) + swiftSettings.append(.define("SAGA_RENDER", .when(platforms: [.wasi]))) +} +// Opt-in native SAGA renderer (Vulkan on Windows, Linux, and Android). +if isSAGANativeEnabled { + for product in ["SAGA", "SAGACompiler", "SAGAVulkan"] { + adaRenderDependencies.append(.product(name: product, package: "SAGA", condition: .when(platforms: [.windows, .linux, .android]))) + } + swiftSettings.append(.define("SAGA_RENDER", .when(platforms: [.windows, .linux, .android]))) + swiftSettings.append(.define("SAGA_NATIVE", .when(platforms: [.windows, .linux, .android]))) + adaRenderDependencies.append(.target(name: "AdaWGSLCompiler", condition: .when(platforms: [.windows, .linux, .android]))) +} +if isSAGAMetalEnabled { + let sagaMetalPlatforms: [Platform] = [.macOS, .iOS, .tvOS] + for product in ["SAGA", "SAGACompiler", "SAGAMetal"] { + adaRenderDependencies.append(.product(name: product, package: "SAGA", condition: .when(platforms: sagaMetalPlatforms))) + } + adaRenderDependencies.append(.target(name: "AdaWGSLCompiler", condition: .when(platforms: sagaMetalPlatforms))) + swiftSettings.append(.define("SAGA_RENDER", .when(platforms: sagaMetalPlatforms))) + swiftSettings.append(.define("SAGA_METAL", .when(platforms: sagaMetalPlatforms))) +} if dlssBuildConfiguration != nil { adaRenderDependencies.append(.target(name: "CWebGPUDLSS", condition: .when(platforms: [.windows], traits: [.wgpuTrait]))) } @@ -792,7 +821,15 @@ targets.append( ) ) +if usesSAGACompiler || isSAGANativeEnabled || isSAGAMetalEnabled { + targets.append(.target( + name: "AdaWGSLCompiler", + dependencies: [.product(name: "SAGACompiler", package: "SAGA")] + )) +} + if isWebExportEnabled { + products.append(.library(name: "AdaWGSLCompiler", targets: ["AdaWGSLCompiler"])) products.append(.executable(name: "AdaShaderGraphBaker", targets: ["AdaShaderGraphBaker"])) products.append( .plugin(name: "AdaWebExportPlugin", targets: [ @@ -804,7 +841,7 @@ if isWebExportEnabled { .executableTarget( name: "AdaShaderTranspilerTool", dependencies: [ - "SPIRVCompiler" + "SPIRVCompiler", "AdaWGSLCompiler" ], path: "Plugins/AdaShaderTranspilerTool" ) @@ -812,7 +849,7 @@ if isWebExportEnabled { targets.append(.executableTarget( name: "AdaShaderGraphBaker", - dependencies: ["AdaAssets", "AdaCorePipelines", "AdaRender", "AdaSprite"], + dependencies: ["AdaAssets", "AdaCorePipelines", "AdaRender", "AdaSprite", "AdaWGSLCompiler"], path: "Plugins/AdaShaderGraphBaker" )) @@ -826,7 +863,7 @@ if isWebExportEnabled { ), permissions: [ .writeToPackageDirectory(reason: "Write the exported web app bundle to the requested output directory"), - .allowNetworkConnections(scope: .all(), reason: "Download Dawn/Tint when Tint is not installed and shaders need WGSL generation") + .allowNetworkConnections(scope: .all(), reason: "Resolve SwiftPM dependencies for the web build") ] ), dependencies: [ @@ -902,7 +939,7 @@ targets.append( ) // MARK: Build Plugins -if isWGPUEnabled { +if isWGPUEnabled && usesSwan { products.append(.plugin(name: "WebGPUTintPlugin", targets: ["WebGPUTintPlugin"])) targets.append( .plugin( @@ -1466,7 +1503,9 @@ targets += [ ], resources: [ .copy("Fixtures/Images") - ] + ], + swiftSettings: (isSAGANativeEnabled ? [.define("SAGA_NATIVE")] : []) + + (isSAGAMetalEnabled ? [.define("SAGA_METAL", .when(platforms: [.macOS]))] : []) ), .testTarget( name: "AdaInputTests", @@ -1566,14 +1605,16 @@ package.targets.append(.target( package.dependencies += [ .package(path: "Editor/StudioCore"), - .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), + // The supported WASI SDK traps in _swift_stdlib_immortalize with Swift Collections 1.7.6. + isWebExportEnabled + ? .package(url: "https://github.com/apple/swift-collections", exact: "1.4.1") + : .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), .package(url: "https://github.com/apple/swift-log", from: "1.8.0"), .package(url: "https://github.com/apple/swift-distributed-tracing", from: "1.0.0"), .package(url: "https://github.com/apple/swift-service-context", from: "1.0.0"), .package(url: "https://github.com/apple/swift-numerics", from: "1.1.1"), .package(url: "https://github.com/apple/swift-atomics", from: "1.3.0"), .package(url: "https://github.com/the-swift-collective/zlib.git", from: "1.3.2"), - .package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "0.2.1"), .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.7.3"), // TODO: SpectralDragon packages should move to AdaEngine ProcessInfo.processInfo.environment["ADAENGINE_YAMS_PACKAGE_PATH"].map { .package(name: "Yams", path: $0) } @@ -1596,7 +1637,7 @@ for target in package.targets { } } #else -// Swan checks in BridgeJS generated with this ABI; update both together. +// Keep the bundled JavaScriptKit BridgeJS runtime at the same ABI version. package.dependencies.append(.package(url: "https://github.com/swiftwasm/JavaScriptKit.git", exact: "0.59.0")) #endif @@ -1613,7 +1654,21 @@ if !isWebExportEnabled && !isAndroidBuildEnabled { ) } -if !isHeadlessCIEnabled { +if usesSAGACompiler || isSAGANativeEnabled || isSAGAMetalEnabled { + if let sagaPath = ProcessInfo.processInfo.environment["ADAENGINE_SAGA_PACKAGE_PATH"], !sagaPath.isEmpty { + package.dependencies.append(.package(name: "SAGA", path: sagaPath)) + } else if isWebExportEnabled || isSAGAMetalEnabled { + package.dependencies.append(.package( + url: "https://github.com/AdaEngine/SAGA.git", + revision: "8be633b5be0f747e0aa4e030fcad03c4ca17908f" + )) + } else { + let sagaPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("SAGA").path + package.dependencies.append(.package(name: "SAGA", path: sagaPath)) + } +} +if usesSwan { package.dependencies.append( ProcessInfo.processInfo.environment["ADAENGINE_SWAN_PACKAGE_PATH"].map { .package(name: "swan", path: $0) } ?? .package(url: "https://github.com/AdaEngine/swan", revision: "790df787281e9cf1c2358a11bb42b2ec2df01029") diff --git a/Plugins/AdaShaderGraphBaker/AdaShaderGraphBaker.swift b/Plugins/AdaShaderGraphBaker/AdaShaderGraphBaker.swift index 84989657c..9522be977 100644 --- a/Plugins/AdaShaderGraphBaker/AdaShaderGraphBaker.swift +++ b/Plugins/AdaShaderGraphBaker/AdaShaderGraphBaker.swift @@ -2,6 +2,7 @@ import AdaAssets import AdaCorePipelines import AdaRender import AdaSprite +import AdaWGSLCompiler import Foundation #if os(Windows) import CRT @@ -24,7 +25,7 @@ struct AdaShaderGraphBaker { private static func bake() throws { let args = CommandLine.arguments - guard args.count == 3 else { throw BakeError.message("Usage: AdaShaderGraphBaker ") } + guard args.count == 2 else { throw BakeError.message("Usage: AdaShaderGraphBaker ") } let root = URL(fileURLWithPath: args[1]).standardizedFileURL let files = (FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey])?.allObjects as? [URL] ?? []) .filter { (try? $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true }.sorted { $0.path < $1.path } @@ -53,7 +54,7 @@ struct AdaShaderGraphBaker { var seen = Set() for (path, graph) in graphs.sorted(by: { $0.key < $1.key }) + inline.enumerated().map({ ("inline:\($0.offset)", $0.element) }) { do { - let program = try graph.compileGLSL() + let program = try graph.compileWGSL() for texture in program.textures { guard let texturePath = texture.path else { continue } let resolved: String @@ -62,44 +63,39 @@ struct AdaShaderGraphBaker { } else { throw BakeError.message("Texture needs a packaged resource path: \(texturePath)") } paths.insert(resolved) } - let templates: [(String, [String])] + // Every template the runtime may pair this graph with, linked from the same WESL the native renderer uses. + var templates: [(name: String, source: String, root: URL, macros: [String])] = [] if graph.target == .sprite { - templates = try [false, true].map { (try SpriteGraphPipeline.fragmentTemplate(instanced: $0), []) } + let root = SpriteGraphPipeline.weslPackageRoot + for instanced in [false, true] { + templates.append((instanced ? "sprite_instanced" : "sprite", try SpriteGraphPipeline.weslTemplate(instanced: instanced), root, [])) + } } else { - var variants: [(String, [String])] = [] + let root = ShaderGraph3DPipeline.weslPackageRoot + let surface = try ShaderGraph3DPipeline.weslTemplate() for mask in textureMasks.sorted() { for layers: UInt32 in 0..<8 { for morph: UInt32 in [0, 1 << 11] { let features = ShaderGraphBakeVariants.features(textureMask: mask, layers: layers, morph: morph != 0) - variants.append((try ShaderGraph3DPipeline.fragmentTemplate(), features.enablingMacros)) + templates.append(("flat3d", surface, root, features.enablingMacros)) } } } for name in ["directional_shadow_3d", "motion_3d"] { + let text = try ShaderGraph3DPipeline.weslTemplate(path: "Shaders/\(name).wesl") for morph in [false, true] { - variants.append((try ShaderGraph3DPipeline.fragmentTemplate(path: "Shaders/\(name).glsl"), morph ? ["ADA_MORPH"] : [])) + templates.append((name, text, root, morph ? ["ADA_MORPH"] : [])) } } - templates = variants } - for (template, macros) in templates { - let source = program.inserting(in: template) - let identity = source + "\u{0}" + macros.sorted().joined(separator: ",") + for template in templates { + let fragment = try ShaderGraphArchive.Fragment.bake( + template: template.name, templateSource: template.source, program: program, + packageRoot: template.root, macros: template.macros + ) + let identity = fragment.source + "\u{0}" + template.macros.sorted().joined(separator: ",") guard seen.insert(identity).inserted else { continue } - let shader = ShaderSource() - shader.setSource(source, for: .fragment) - let compiler = ShaderCompiler(shaderSource: shader) - compiler.setMacro("ADA_SHADER_GRAPH", value: "1", for: .fragment) - for macro in macros { compiler.setMacro(macro, value: "1", for: .fragment) } - let binary = try compiler.exportFragment() - let wgsl = try tint(args[2], data: binary.data, entryPoint: binary.entryPoint) - fragments.append(.init( - source: source, - defines: Dictionary(uniqueKeysWithValues: macros.map { ($0, "1") }), - entryPoint: binary.entryPoint, - wgsl: wgsl, - reflection: binary.reflection - )) + fragments.append(fragment) } } catch { throw BakeError.message("Shader Graph \(path) [\(graph.target.rawValue)]: \(error)") } } @@ -158,27 +154,6 @@ struct AdaShaderGraphBaker { } } - private static func tint(_ executable: String, data: Data, entryPoint: String) throws -> String { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let input = directory.appendingPathComponent("shader.spv") - let output = directory.appendingPathComponent("shader.wgsl") - let errors = directory.appendingPathComponent("errors.txt") - try data.write(to: input) - FileManager.default.createFile(atPath: errors.path, contents: nil) - let errorHandle = try FileHandle(forWritingTo: errors) - defer { try? errorHandle.close() } - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = ["--format", "wgsl", "--allow-non-uniform-derivatives", "true", "-o", output.path, input.path] - process.standardError = errorHandle - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { throw BakeError.message(try String(contentsOf: errors, encoding: .utf8)) } - return try String(contentsOf: output, encoding: .utf8).replacingOccurrences(of: "fn main(", with: "fn \(entryPoint)(") - } - private enum BakeError: Error, CustomStringConvertible { case message(String) var description: String { diff --git a/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift b/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift index 4787e05ac..2c46129dd 100644 --- a/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift +++ b/Plugins/AdaShaderTranspilerTool/AdaShaderTranspilerTool.swift @@ -1,3 +1,4 @@ +import AdaWGSLCompiler import Foundation import SPIRVCompiler #if os(Windows) @@ -21,6 +22,10 @@ struct AdaShaderTranspilerTool { private static func run() throws { let options = try Options(arguments: CommandLine.arguments.dropFirst()) + if options.input.pathExtension == "wesl" { + try compileWESL(options) + return + } let source = try String(contentsOf: options.input, encoding: .utf8) let stages = try GLSLProcessor.splitStages(in: source) @@ -41,11 +46,7 @@ struct AdaShaderTranspilerTool { ) let (entryPoint, mainSource) = GLSLProcessor.dropEntryPoint(in: processedSource) let spirv = try compileSPIRV(source: mainSource, stage: stageSource.stage, defines: options.defines) - let wgsl = try runTint( - tintExecutable: options.tintExecutable, - spirv: spirv, - entryPoint: entryPoint - ) + let wgsl = try AdaWGSLCompiler.compile(spirv: spirv, entryPoint: entryPoint, stage: stageSource.stage.wgslStage) let stem = ([options.input.deletingPathExtension().lastPathComponent] + options.defines).joined(separator: ".") let output = options.outputDirectory .appending(component: stem, directoryHint: .notDirectory) @@ -57,6 +58,70 @@ struct AdaShaderTranspilerTool { } } + private static func compileWESL(_ options: Options) throws { + guard options.defines.isEmpty, options.moduleIncludes.isEmpty else { + throw ToolError.invalidArgument("Use --feature and --wesl-root for WESL sources.") + } + let input = options.input.standardizedFileURL + let root = (options.weslRoot ?? input.deletingLastPathComponent()).standardizedFileURL + let features = Dictionary(uniqueKeysWithValues: options.features.map { ($0, true) }) + let artifact = try WESLHostCompiler.compile(sourceFile: input, packageRoot: root, features: features, dependencies: options.weslDependencies) + if options.target != "wgsl" { + try compileNativeStages(artifact, options: options) + return + } + try FileManager.default.createDirectory(at: options.outputDirectory, withIntermediateDirectories: true) + if options.webStages { + var stages: Set = [] + for entry in artifact.entryPoints { + guard stages.insert(entry.stage).inserted else { + throw ToolError.invalidArgument("A bundled WESL shader must have one entry point per active stage.") + } + } + let stem = ([input.lastPathComponent] + options.features).joined(separator: ".") + for stage in stages.sorted() { + let suffix = switch stage { + case "vertex": "vert" + case "fragment": "frag" + case "compute": "comp" + default: throw ToolError.invalidArgument("Unsupported WESL stage '\(stage)'.") + } + let output = options.outputDirectory.appendingPathComponent(stem) + .appendingPathExtension(suffix).appendingPathExtension("wgsl") + try artifact.wgsl.write(to: output, atomically: true, encoding: .utf8) + print(output.path) + } + return + } + let stem = ([input.deletingPathExtension().lastPathComponent] + options.features).joined(separator: ".") + let output = options.outputDirectory.appendingPathComponent(stem).appendingPathExtension("wgsl") + try artifact.wgsl.write(to: output, atomically: true, encoding: .utf8) + print(output.path) + } + + private static func compileNativeStages(_ module: WESLHostCompiler.Artifact, options: Options) throws { + guard let target = WESLBackendCompiler.Target(rawValue: options.target) else { + throw ToolError.invalidArgument("Unsupported WESL backend target") + } + var stages: Set = [] + for entry in module.entryPoints { + guard stages.insert(entry.stage).inserted else { + throw ToolError.invalidArgument("A bundled WESL shader must have one entry point per active stage.") + } + } + // Compile the complete batch before writing any output if a stage is unsupported. + let compiled = try WESLBackendCompiler.compile(module, to: target) + try FileManager.default.createDirectory(at: options.outputDirectory, withIntermediateDirectories: true) + let stem = ([options.input.deletingPathExtension().lastPathComponent] + options.features).joined(separator: ".") + let suffix = target == .spirv ? "spv" : "msl" + for entry in compiled { + let output = options.outputDirectory.appendingPathComponent(stem) + .appendingPathExtension(entry.fileExtension).appendingPathExtension(suffix) + try entry.data.write(to: output, options: .atomic) + print(output.path) + } + } + private static func compileSPIRV(source: String, stage: ShaderStage, defines: [String]) throws -> Data { var error: UnsafePointer? let source = source.insertingDefines(defines) @@ -80,64 +145,29 @@ struct AdaShaderTranspilerTool { return Data(bytes: bytes, count: Int(binary.length)) } - - private static func runTint(tintExecutable: URL, spirv: Data, entryPoint: String) throws -> String { - let directory = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString, directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - defer { - try? FileManager.default.removeItem(at: directory) - } - let temporaryFile = directory.appendingPathComponent("input.spv") - let outputFile = directory.appendingPathComponent("output.wgsl") - let errorFile = directory.appendingPathComponent("diagnostics.txt") - try spirv.write(to: temporaryFile) - try Data().write(to: errorFile) - - let process = Process() - let standardError = try FileHandle(forWritingTo: errorFile) - defer { try? standardError.close() } - process.executableURL = tintExecutable - // GLSL materials may compute derivatives under data-dependent control flow. - // Preserve that source behavior when translating SPIR-V; Tint emits the - // corresponding WGSL derivative_uniformity diagnostic directive. - // A generated shader or diagnostic can exceed a pipe's buffer before the child exits. - process.arguments = ["--format", "wgsl", "--allow-non-uniform-derivatives", "true", "-o", outputFile.path(), temporaryFile.path()] - process.standardOutput = FileHandle.nullDevice - process.standardError = standardError - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - let errorOutput = String(decoding: try Data(contentsOf: errorFile), as: UTF8.self) - throw ToolError.tintFailed(errorOutput.isEmpty ? "Tint exited with status \(process.terminationStatus)." : errorOutput) - } - - let output = String(decoding: try Data(contentsOf: outputFile), as: UTF8.self) - guard !output.isEmpty else { - throw ToolError.tintFailed("Tint produced no WGSL output.") - } - - let renamed = output.replacingOccurrences(of: "fn main(", with: "fn \(entryPoint)(") - return """ - // Generated from GLSL by AdaShaderTranspilerTool. - \(renamed) - """ - } } private struct Options { let input: URL let outputDirectory: URL - let tintExecutable: URL let moduleIncludes: [String: URL] let defines: [String] + let weslRoot: URL? + let weslDependencies: [String: URL] + let features: [String] + let webStages: Bool + let target: String init(arguments: ArraySlice) throws { var input: URL? var outputDirectory: URL? - var tintExecutable: URL? var moduleIncludes: [String: URL] = [:] var defines: Set = [] + var weslRoot: URL? + var weslDependencies: [String: URL] = [:] + var features: Set = [] + var webStages = false + var target = "wgsl" var iterator = arguments.makeIterator() while let argument = iterator.next() { @@ -146,8 +176,24 @@ private struct Options { input = try Self.nextURL(from: &iterator, for: argument) case "--output-directory": outputDirectory = try Self.nextURL(from: &iterator, for: argument) - case "--tint": - tintExecutable = try Self.nextURL(from: &iterator, for: argument) + case "--wesl-root": + weslRoot = try Self.nextURL(from: &iterator, for: argument) + case "--wesl-dependency": + let value = try Self.nextValue(from: &iterator, for: argument) + let parts = value.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty, weslDependencies[parts[0]] == nil else { + throw ToolError.invalidArgument("--wesl-dependency expects a unique PackageName=/path/to/package") + } + weslDependencies[parts[0]] = URL(fileURLWithPath: parts[1]) + case "--feature": + features.insert(try Self.nextValue(from: &iterator, for: argument)) + case "--target": + target = try Self.nextValue(from: &iterator, for: argument) + guard ["wgsl", "msl", "spirv"].contains(target) else { + throw ToolError.invalidArgument("--target expects wgsl, msl, or spirv") + } + case "--web-stages": + webStages = true case "--define": let name = try Self.nextValue(from: &iterator, for: argument) guard name.range(of: #"^[A-Z_][A-Z0-9_]*$"#, options: .regularExpression) != nil else { @@ -174,15 +220,24 @@ private struct Options { guard let outputDirectory else { throw ToolError.invalidArgument("Missing --output-directory") } - guard let tintExecutable else { - throw ToolError.invalidArgument("Missing --tint") + if input.pathExtension != "wesl", weslRoot != nil || !weslDependencies.isEmpty || !features.isEmpty || webStages { + throw ToolError.invalidArgument("WESL options require a .wesl input.") } - + if target != "wgsl", input.pathExtension != "wesl" { + throw ToolError.invalidArgument("Native backend targets require a .wesl input.") + } + if webStages, target != "wgsl" { + throw ToolError.invalidArgument("--web-stages requires WGSL output") + } + self.target = target self.input = input self.outputDirectory = outputDirectory - self.tintExecutable = tintExecutable self.moduleIncludes = moduleIncludes self.defines = defines.sorted() + self.weslRoot = weslRoot + self.weslDependencies = weslDependencies + self.features = features.sorted() + self.webStages = webStages } private static func nextURL(from iterator: inout I, for argument: String) throws -> URL where I.Element == String { @@ -445,6 +500,14 @@ private enum ShaderStage { } } + var wgslStage: AdaWGSLCompiler.Stage { + switch self { + case .vertex: .vertex + case .fragment: .fragment + case .compute: .compute + } + } + var shadercStage: shaderc_stage { switch self { case .vertex: @@ -465,7 +528,6 @@ private enum ToolError: LocalizedError, CustomStringConvertible { case includeNotFound(String) case includeCycle(String) case glslCompilationFailed(String) - case tintFailed(String) var errorDescription: String? { description @@ -474,7 +536,10 @@ private enum ToolError: LocalizedError, CustomStringConvertible { var description: String { switch self { case .help: - "Usage: AdaShaderTranspilerTool --input shader.glsl --output-directory --tint [--define NAME] [--module-include AdaEngine=]" + """ + Usage: AdaShaderTranspilerTool --input shader.glsl --output-directory [--define NAME] [--module-include AdaEngine=] + AdaShaderTranspilerTool --input shader.wesl --output-directory [--wesl-root ] [--wesl-dependency name=] [--feature name] [--target wgsl|msl|spirv] + """ case .invalidArgument(let message): message case .glslangInitializationFailed: @@ -487,8 +552,6 @@ private enum ToolError: LocalizedError, CustomStringConvertible { "Shader include cycle detected at \(path)." case .glslCompilationFailed(let message): "GLSL compilation failed: \(message)" - case .tintFailed(let message): - "Tint conversion failed: \(message)" } } } diff --git a/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift b/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift index 5fa36dcee..6b0338346 100644 --- a/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift +++ b/Plugins/AdaWebExportPlugin/AdaWebExportPlugin.swift @@ -10,6 +10,7 @@ import PackagePlugin struct AdaWebExportPlugin: CommandPlugin { // Runtime files belong to the plugin package, including when used by a dependent game. private static var assetsDirectory: URL { URL(fileURLWithPath: #filePath).deletingLastPathComponent() } + private static let renderBundle = "AdaEngine_AdaRender.resources" private static let flat3DBundle = "AdaEngine_AdaCorePipelines.resources" private static let flat3DVariants = [["ADA_TERRAIN"], ["ADA_WIREFRAME"], ["ADA_TERRAIN", "ADA_WIREFRAME"]] @@ -37,13 +38,14 @@ struct AdaWebExportPlugin: CommandPlugin { guard buildDirectory.resolvingSymlinksInPath().standardizedFileURL != sharedBuild else { throw ExportError.unsafeScratchDirectory } - try prepareBuildDirectory(buildDirectory, packageDirectory: context.package.directoryURL) + try FileManager.default.createDirectory(at: buildDirectory, withIntermediateDirectories: true) + try detachLegacyDependencyLinks(in: buildDirectory) try restorePortableDependencyPatches(in: buildDirectory) // Resolve first so platform-compatibility patches run on actual private // checkouts, including a cold scratch directory with no Yams checkout. try run( executable: "/usr/bin/env", - arguments: ["swift", "package", "resolve", "--scratch-path", buildDirectory.path(), "--disable-sandbox", "--disable-keychain"], + arguments: ["swift", "package", "resolve", "--scratch-path", buildDirectory.path(percentEncoded: false), "--disable-sandbox", "--disable-keychain"], workingDirectory: context.package.directoryURL ) try prepareDependenciesForWASI(in: buildDirectory) @@ -55,7 +57,7 @@ struct AdaWebExportPlugin: CommandPlugin { "swift", "build", "--scratch-path", - buildDirectory.path(), + buildDirectory.path(percentEncoded: false), "--disable-sandbox", "--skip-update", "--disable-build-manifest-caching", @@ -76,7 +78,7 @@ struct AdaWebExportPlugin: CommandPlugin { "-Xcc", Self.assetsDirectory.appending( components: "Compatibility", "AdaScriptWASI.h" - ).path() + ).path(percentEncoded: false) ], workingDirectory: context.package.directoryURL ) @@ -96,13 +98,43 @@ struct AdaWebExportPlugin: CommandPlugin { shaderTranspiler: try context.tool(named: "AdaShaderTranspilerTool").url ) - Diagnostics.remark("Exported AdaEngine web app to \(options.outputDirectory.path())") + Diagnostics.remark("Exported AdaEngine web app to \(options.outputDirectory.path(percentEncoded: false))") if options.serve { try serve(directory: options.outputDirectory) } } + /// Copies the JavaScript host from the package that provides the Swift host. + private func copySAGAWebHost(package: Package, to output: URL) throws { + guard let saga = sagaWebHostPackage(in: package) else { + throw ExportError.sagaWebHostNotResolved + } + let host = saga.directoryURL.appending(components: "web", "webgpu.mjs") + guard FileManager.default.fileExists(atPath: host.path(percentEncoded: false)) else { + throw ExportError.sagaWebHostAssetMissing(host.path(percentEncoded: false)) + } + try replaceItem(at: output.appending(component: "saga-webgpu.mjs"), with: host) + let golden = saga.directoryURL.appending(components: "web", "golden.mjs") + if FileManager.default.fileExists(atPath: golden.path(percentEncoded: false)) { + try replaceItem(at: output.appending(component: "saga-golden.mjs"), with: golden) + } + } + + private func sagaWebHostPackage(in root: Package) -> Package? { + // A game depends on AdaEngine directly and SAGA transitively. + var pending = [root] + var visited: Set = [] + while let package = pending.popLast() { + guard visited.insert(package.directoryURL).inserted else { continue } + if package.products.contains(where: { $0.name == "SAGAWebHost" }) { + return package + } + pending.append(contentsOf: package.dependencies.reversed().map(\.package)) + } + return nil + } + private func detectWasmSDK() throws -> String { let output = try capture( executable: "/usr/bin/env", @@ -142,7 +174,7 @@ struct AdaWebExportPlugin: CommandPlugin { includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { - throw ExportError.wasmNotFound(product, buildDirectory.path()) + throw ExportError.wasmNotFound(product, buildDirectory.path(percentEncoded: false)) } let candidates = enumerator.compactMap { item -> URL? in @@ -159,26 +191,21 @@ struct AdaWebExportPlugin: CommandPlugin { } guard let wasm = candidates.sorted(by: { $0.path.count < $1.path.count }).last else { - throw ExportError.wasmNotFound(product, buildDirectory.path()) + throw ExportError.wasmNotFound(product, buildDirectory.path(percentEncoded: false)) } return wasm } - private func prepareBuildDirectory(_ buildDirectory: URL, packageDirectory: URL) throws { - let fileManager = FileManager.default - try fileManager.createDirectory(at: buildDirectory, withIntermediateDirectories: true) - - let packageBuildDirectory = packageDirectory.appending(component: ".build", directoryHint: .isDirectory) - // Artifact and workspace-state paths can point into an unrelated host - // scratch build. Let the isolated WASM build resolve those itself. + private func detachLegacyDependencyLinks(in buildDirectory: URL) throws { + let manager = FileManager.default + let scratchPrefix = buildDirectory.path.hasSuffix("/") ? buildDirectory.path : buildDirectory.path + "/" for name in ["checkouts", "repositories"] { - let source = packageBuildDirectory.appending(component: name) - let destination = buildDirectory.appending(component: name) - guard fileManager.fileExists(atPath: source.path()), !fileManager.fileExists(atPath: destination.path()) else { - continue - } - try fileManager.createSymbolicLink(at: destination, withDestinationURL: source) + let link = buildDirectory.appendingPathComponent(name) + guard let destination = try? manager.destinationOfSymbolicLink(atPath: link.path) else { continue } + let target = destination.hasPrefix("/") ? URL(fileURLWithPath: destination) : buildDirectory.appendingPathComponent(destination) + guard !target.resolvingSymlinksInPath().standardizedFileURL.path.hasPrefix(scratchPrefix) else { continue } + try manager.removeItem(at: link) } } @@ -186,13 +213,13 @@ struct AdaWebExportPlugin: CommandPlugin { let fileManager = FileManager.default let checkouts = buildDirectory.appending(component: "checkouts", directoryHint: .isDirectory) let source = checkouts.appending(components: "Yams", "Sources", "Yams", "Representer.swift") - guard fileManager.fileExists(atPath: source.path()) else { return } + guard fileManager.fileExists(atPath: source.path(percentEncoded: false)) else { return } let original = try String(contentsOf: source, encoding: .utf8) let expression = "String(format: \"%.*g\", DBL_DECIMAL_DIG, value)" // The pinned Yams formatter uses a C macro that Foundation does not export on WASI. // Double.significandBitCount is 52; IEEE binary64 round-trips with 17 decimal digits. - // Work only on a private copy: prepareBuildDirectory may have linked shared checkouts. + // Older export scratch directories may still point to shared checkouts. let resolvedCheckouts = checkouts.resolvingSymlinksInPath().standardizedFileURL let scratchRoot = buildDirectory.resolvingSymlinksInPath().standardizedFileURL.path if !resolvedCheckouts.path.hasPrefix(scratchRoot.hasSuffix("/") ? scratchRoot : scratchRoot + "/") { @@ -210,7 +237,7 @@ struct AdaWebExportPlugin: CommandPlugin { Diagnostics.remark("Applied portable floating-point precision to the isolated Yams web-build checkout") } let vmSource = checkouts.appending(components: "gravity-lang", "binding", "GravitySwift", "GravityVirtualMachine.swift") - guard fileManager.fileExists(atPath: vmSource.path()) else { return } + guard fileManager.fileExists(atPath: vmSource.path(percentEncoded: false)) else { return } let vmOriginal = try String(contentsOf: vmSource, encoding: .utf8) // Corelibs Foundation takes unsigned byte buffers, unlike Darwin's C-imported initializer. let vmUpdated = vmOriginal @@ -244,11 +271,16 @@ struct AdaWebExportPlugin: CommandPlugin { for (relative, patched, original) in replacements { let file = checkouts.appendingPathComponent(relative) guard file.resolvingSymlinksInPath().standardizedFileURL.path.hasPrefix(base), - FileManager.default.fileExists(atPath: file.path()) else { continue } + FileManager.default.fileExists(atPath: file.path(percentEncoded: false)) else { continue } let content = try String(contentsOf: file, encoding: .utf8) guard content.contains(patched), let checkout = relative.split(separator: "/").first else { continue } let sourcePath = relative.split(separator: "/").dropFirst().joined(separator: "/") - let committed = try capture(executable: "/usr/bin/env", arguments: ["git", "-C", checkouts.appendingPathComponent(String(checkout)).path(), "show", "HEAD:" + sourcePath], workingDirectory: nil) + let checkoutPath = checkouts.appendingPathComponent(String(checkout)).path(percentEncoded: false) + let committed = try capture( + executable: "/usr/bin/env", + arguments: ["git", "-C", checkoutPath, "show", "HEAD:" + sourcePath], + workingDirectory: nil + ) if committed.contains(original) { try content.replacingOccurrences(of: patched, with: original).write(to: file, atomically: true, encoding: .utf8) } } } @@ -313,10 +345,9 @@ struct AdaWebExportPlugin: CommandPlugin { return URL(fileURLWithPath: String(file.path[.. URL { let engine = Self.assetsDirectory.deletingLastPathComponent().deletingLastPathComponent() let cache = (scratch ?? packageDirectory.appendingPathComponent(".build-web-export")) @@ -496,33 +526,28 @@ struct AdaWebExportPlugin: CommandPlugin { shaderTranspiler: URL, requiresAllShaders: Bool ) throws -> [ResourceManifestEntry] { - let shaderURLs = try resourceBundles - .flatMap { bundle in - try regularFiles(in: bundle.source) + let files: [URL] = try resourceBundles.flatMap { try regularFiles(in: $0.source) } + let shaders = files.filter { file in + switch file.pathExtension { + case "wesl": true + case "glsl": containsShaderStagePragma(file) + default: false } - .filter { $0.pathExtension == "glsl" && containsShaderStagePragma($0) } - .sorted { $0.path() < $1.path() } + } + let shaderURLs = shaders.sorted { $0.path(percentEncoded: false) < $1.path(percentEncoded: false) } guard !shaderURLs.isEmpty else { - Diagnostics.remark("No GLSL shader resources need WGSL generation") + Diagnostics.remark("No shader resources need WGSL generation") return [] } - if requiresAllShaders, - let cached = try cachedWGSLShaders( - resourceBundles: resourceBundles, - outputDirectory: outputDirectory, - packageDirectory: packageDirectory - ) { - Diagnostics.remark("Reused \(cached.count) verified WGSL shaders for the Web Player") - return cached - } - - let tintExecutable = try self.tintExecutable(packageDirectory: packageDirectory) let moduleIncludeArguments = shaderModuleIncludeArguments( resourceBundles: resourceBundles, packageDirectory: packageDirectory ) + let weslDependencies = shaderLibrary("WESL", resourceBundles: resourceBundles, packageDirectory: packageDirectory) + .map { ["--wesl-dependency", "ada_engine=\($0.path(percentEncoded: false))"] } ?? [] + var entries: [ResourceManifestEntry] = [] for shaderURL in shaderURLs { guard let resourceBundle = resourceBundles.first(where: { shaderURL.isDescendant(of: $0.source) }) else { @@ -533,21 +558,34 @@ struct AdaWebExportPlugin: CommandPlugin { var arguments = [ "--input", - shaderURL.path(), + shaderURL.path(percentEncoded: false), "--output-directory", - shaderOutputDirectory.path(), - "--tint", - tintExecutable.path() + shaderOutputDirectory.path(percentEncoded: false) ] - arguments.append(contentsOf: moduleIncludeArguments) + if shaderURL.pathExtension == "wesl" { + arguments.append("--web-stages") + arguments.append(contentsOf: weslDependencies) + } else { + arguments.append(contentsOf: moduleIncludeArguments) + } - let needsVariants = resourceBundle.source.lastPathComponent == Self.flat3DBundle - && shaderURL.relativePath(from: resourceBundle.source) == "Shaders/flat3d.glsl" - for defines in [[]] + (needsVariants ? Self.flat3DVariants : []) { + let variants: [[String]] + if resourceBundle.source.lastPathComponent == Self.flat3DBundle { + variants = switch shaderURL.relativePath(from: resourceBundle.source) { + case "Shaders/flat3d.glsl": Self.flat3DVariants + case "Shaders/directional_shadow_3d.wesl", "Shaders/motion_3d.wesl": [["ADA_MORPH"]] + case "Shaders/meshlet_map_3d.wesl": [["MAP_RESPONSE"], ["MAP_SURFACE"], ["MAP_RESPONSE", "MAP_SURFACE"]] + default: [] + } + } else { + variants = [] + } + let variantFlag = shaderURL.pathExtension == "wesl" ? "--feature" : "--define" + for defines in [[]] + variants { do { let generatedOutput = try WebExportSupport.captureStandardOutput( - executable: shaderTranspiler.path(), - arguments: arguments + defines.flatMap { ["--define", $0] }, + executable: shaderTranspiler.path(percentEncoded: false), + arguments: arguments + defines.flatMap { [variantFlag, $0] }, workingDirectory: packageDirectory ) let generatedEntries = generatedOutput @@ -555,14 +593,14 @@ struct AdaWebExportPlugin: CommandPlugin { .map(String.init) .compactMap { outputPath -> ResourceManifestEntry? in let outputURL = URL(fileURLWithPath: outputPath) - guard FileManager.default.fileExists(atPath: outputURL.path()) else { + guard FileManager.default.fileExists(atPath: outputURL.path(percentEncoded: false)) else { return nil } let absoluteBuildURL = shaderURL .deletingLastPathComponent() .appending(component: outputURL.lastPathComponent, directoryHint: .notDirectory) return ResourceManifestEntry( - path: absoluteBuildURL.path(), + path: absoluteBuildURL.path(percentEncoded: false), // The WGSL exists in the export, not in the SwiftPM resource bundle. url: browserRelativeURL(forRelativePath: outputURL.relativePath(from: outputDirectory)) ) @@ -575,85 +613,12 @@ struct AdaWebExportPlugin: CommandPlugin { } } - Diagnostics.remark("Generated \(entries.count) WGSL shader resources with Tint") - return entries - } - - private func cachedWGSLShaders( - resourceBundles: [ResourceBundleExport], - outputDirectory: URL, - packageDirectory: URL - ) throws -> [ResourceManifestEntry]? { - let cacheRoot = Self.assetsDirectory.appending( - components: "Runtime", "WGSLCache", - directoryHint: .isDirectory - ) - guard - let manifestData = FileManager.default.contents(atPath: cacheRoot.appending(component: "manifest.json").path()), - let manifest = try? JSONDecoder().decode(CachedShaderManifest.self, from: manifestData), - manifest.schemaVersion == 1 - else { return nil } - if resourceBundles.contains(where: { $0.source.lastPathComponent == Self.flat3DBundle }) { - let requiredVariants = Self.flat3DVariants.flatMap { defines in - let stem = Self.flat3DBundle + "/Shaders/flat3d." + defines.joined(separator: ".") - return [stem + ".vert.wgsl", stem + ".frag.wgsl"] - } - guard Set(requiredVariants).isSubset(of: manifest.wgslPaths) else { return nil } - } - - var currentSources: [String: URL] = [:] - for bundle in resourceBundles { - for source in try regularFiles(in: bundle.source) where source.pathExtension == "glsl" { - let key = bundle.source.lastPathComponent + "/" + source.relativePath(from: bundle.source) - currentSources[key] = source - } - } - guard Set(currentSources.keys) == Set(manifest.glslHashes.keys), - currentSources.allSatisfy({ key, source in - guard let expected = manifest.glslHashes[key].flatMap({ UInt64($0, radix: 16) }), - let bytes = FileManager.default.contents(atPath: source.path()) - else { return false } - var hash: UInt64 = 14_695_981_039_346_656_037 - for byte in bytes { - hash ^= UInt64(byte) - hash &*= 1_099_511_628_211 - } - return hash == expected - }) - else { - Diagnostics.remark("Precompiled WGSL cache does not match current GLSL sources; using Tint") - return nil - } - - let bundlesByName = Dictionary(uniqueKeysWithValues: resourceBundles.map { ($0.source.lastPathComponent, $0) }) - var entries: [ResourceManifestEntry] = [] - for path in manifest.wgslPaths { - let parts = path.split(separator: "/", omittingEmptySubsequences: false) - guard parts.count >= 2, parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }), - let bundle = bundlesByName[String(parts[0])] - else { return nil } - let relative = parts.dropFirst().joined(separator: "/") - let cached = cacheRoot.appending(path: path, directoryHint: .notDirectory) - guard FileManager.default.fileExists(atPath: cached.path()) else { return nil } - let destination = bundle.destination.appending(path: relative, directoryHint: .notDirectory) - try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) - try replaceItem(at: destination, with: cached) - entries.append(ResourceManifestEntry( - path: bundle.source.appending(path: relative, directoryHint: .notDirectory).path(), - url: browserRelativeURL(forRelativePath: destination.relativePath(from: outputDirectory)) - )) - } + Diagnostics.remark("Generated \(entries.count) WGSL shader resources with SAGACompiler") return entries } - private struct CachedShaderManifest: Decodable { - let schemaVersion: Int - let glslHashes: [String: String] - let wgslPaths: [String] - } - private func containsShaderStagePragma(_ url: URL) -> Bool { - guard let data = FileManager.default.contents(atPath: url.path()), + guard let data = FileManager.default.contents(atPath: url.path(percentEncoded: false)), let source = String(data: data, encoding: .utf8) else { return false } @@ -681,108 +646,21 @@ struct AdaWebExportPlugin: CommandPlugin { } private func shaderModuleIncludeArguments(resourceBundles: [ResourceBundleExport], packageDirectory: URL) -> [String] { - let candidates = resourceBundles.map { - $0.source.appending(components: "Shaders", "Public", directoryHint: .isDirectory) - } + [ + shaderLibrary("Public", resourceBundles: resourceBundles, packageDirectory: packageDirectory) + .map { ["--module-include", "AdaEngine=\($0.path(percentEncoded: false))"] } ?? [] + } + + private func shaderLibrary(_ folder: String, resourceBundles: [ResourceBundleExport], packageDirectory: URL) -> URL? { + let bundled = resourceBundles.first { $0.source.lastPathComponent == Self.renderBundle }? + .source.appending(components: "Shaders", folder, directoryHint: .isDirectory) + let candidates = [ + bundled, packageDirectory.appending( - components: "Sources", "AdaRender", "Assets", "Shaders", "Public", + components: "Sources", "AdaRender", "Assets", "Shaders", folder, directoryHint: .isDirectory ) ] - - guard let publicShaders = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path()) }) else { - return [] - } - - return [ - "--module-include", - "AdaEngine=\(publicShaders.path())" - ] - } - - private func tintExecutable(packageDirectory: URL) throws -> URL { - if let override = ProcessInfo.processInfo.environment["TINT_EXECUTABLE"], !override.isEmpty { - let executable = URL(fileURLWithPath: override) - if FileManager.default.isExecutableFile(atPath: executable.path()) { - Diagnostics.remark("Using Tint compiler from TINT_EXECUTABLE: \(executable.path())") - return executable - } - throw ExportError.tintNotFound(override) - } - - if let executable = builtTintExecutable(packageDirectory: packageDirectory) { - Diagnostics.remark("Using Tint compiler from project tool cache: \(executable.path())") - return executable - } - - if let executable = findExecutableInPATH(named: .tintBinaryName) { - Diagnostics.remark("Using Tint compiler from PATH: \(executable.path())") - return executable - } - - Diagnostics.remark("Tint compiler was not found locally; building Dawn/Tint before WGSL generation") - try buildTintCompiler(packageDirectory: packageDirectory) - - if let executable = builtTintExecutable(packageDirectory: packageDirectory) { - Diagnostics.remark("Using newly built Tint compiler: \(executable.path())") - return executable - } - - if let executable = findExecutableInPATH(named: .tintBinaryName) { - Diagnostics.remark("Using Tint compiler from PATH: \(executable.path())") - return executable - } - - let expectedExecutable = packageDirectory.appending( - components: ".build", "plugins", "WebGPUTintPlugin", "outputs", "bin", .tintBinaryPlatform, .tintBinaryName, - directoryHint: .notDirectory - ) - throw ExportError.tintNotFound(expectedExecutable.path()) - } - - private func builtTintExecutable(packageDirectory: URL) -> URL? { - let cache = ProcessInfo.processInfo.environment["ADAENGINE_TINT_CACHE"].map { URL(fileURLWithPath: $0) } - ?? packageDirectory.appendingPathComponent(".build-tools/tint") - let verifiedTool = cache.appendingPathComponent("bin/\(String.tintBinaryPlatform)/\(String.tintBinaryName)") - if FileManager.default.isExecutableFile(atPath: verifiedTool.path) { - return verifiedTool - } - let executable = packageDirectory.appending( - components: ".build", "plugins", "WebGPUTintPlugin", "outputs", "bin", .tintBinaryPlatform, .tintBinaryName, - directoryHint: .notDirectory - ) - if FileManager.default.isExecutableFile(atPath: executable.path()) { return executable } - // SwiftPM 6 uses plugins/outputs// for dependent plugins. - let plugins = packageDirectory.appending(components: ".build", "plugins") - guard let files = FileManager.default.enumerator(at: plugins, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { return nil } - return files.compactMap { $0 as? URL }.first { - $0.lastPathComponent == .tintBinaryName && $0.pathComponents.contains("WebGPUTintPlugin") - && FileManager.default.isExecutableFile(atPath: $0.path()) - } - } - - private func findExecutableInPATH(named name: String) -> URL? { - let paths = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] - return paths - .map { URL(fileURLWithPath: $0).appending(component: name, directoryHint: .notDirectory) } - .first { FileManager.default.isExecutableFile(atPath: $0.path()) } - } - - private func buildTintCompiler(packageDirectory: URL) throws { - try run( - executable: "/usr/bin/env", - arguments: [ - "swift", - "package", - "--disable-sandbox", - "plugin", - "--allow-network-connections", - "all", - "--allow-writing-to-package-directory", - "build-tint" - ], - workingDirectory: packageDirectory - ) + return candidates.compactMap { $0 }.first(where: { FileManager.default.fileExists(atPath: $0.path(percentEncoded: false)) }) } private func manifestEntries(forResourceBundle bundle: ResourceBundleExport) throws -> [ResourceManifestEntry] { @@ -892,185 +770,17 @@ struct AdaWebExportPlugin: CommandPlugin { directoryHint: .notDirectory ) ] - if let runtime = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path()) }) { + if let runtime = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path(percentEncoded: false)) }) { return runtime } throw ExportError.javascriptKitRuntimeNotFound } - private func writeBridgeJSRuntime(packageDirectory: URL, checkouts: URL, to output: URL) throws { - guard let skeleton = bridgeJSSkeleton(checkouts: checkouts) else { - try """ - export async function createInstantiator() { - return { - addImports: () => {}, - setInstance: () => {}, - createExports: () => ({}), - }; - } - """.write(to: output, atomically: true, encoding: .utf8) - return - } - - if try cachedBridgeJSRuntime(checkouts: checkouts, skeleton: skeleton, output: output) { - Diagnostics.remark("Reused verified BridgeJS runtime for the Web Player") - return - } - - let bridgeJSPackage = try bridgeJSPackageDirectory(checkouts: checkouts) - let bridgeJSTool = try bridgeJSToolExecutable( - bridgeJSPackage: bridgeJSPackage, - packageDirectory: packageDirectory - ) - try runBridgeJSTool( - executable: bridgeJSTool, - skeleton: skeleton, - output: output, - workingDirectory: packageDirectory - ) - } - - private func cachedBridgeJSRuntime(checkouts: URL, skeleton: URL, output: URL) throws -> Bool { - let cacheRoot = Self.assetsDirectory.appending( - components: "Runtime", "BridgeJSCache", - directoryHint: .isDirectory - ) - guard - let data = FileManager.default.contents(atPath: cacheRoot.appending(component: "manifest.json").path()), - let manifest = try? JSONDecoder().decode(CachedBridgeJSManifest.self, from: data), - manifest.schemaVersion == 1, - let expectedSkeleton = UInt64(manifest.skeletonFNV64, radix: 16), - let expectedRuntime = UInt64(manifest.runtimeFNV64, radix: 16), - let skeletonBytes = FileManager.default.contents(atPath: skeleton.path()), - let runtime = try? javascriptKitRuntime(checkouts: checkouts), - let runtimeBytes = FileManager.default.contents(atPath: runtime.path()), - cachedFNV64(skeletonBytes) == expectedSkeleton, - cachedFNV64(runtimeBytes) == expectedRuntime - else { return false } - let cached = cacheRoot.appending(component: "bridge-js.js", directoryHint: .notDirectory) - guard FileManager.default.fileExists(atPath: cached.path()) else { return false } - try replaceItem(at: output, with: cached) - // A copied cache can predate the previous export. Fresh mtime prevents HTTP - // conditional requests from retaining an older bridge at this same URL. - try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: output.path()) - return true - } - - private func cachedFNV64(_ data: Data) -> UInt64 { - var hash: UInt64 = 14_695_981_039_346_656_037 - for byte in data { - hash ^= UInt64(byte) - hash &*= 1_099_511_628_211 - } - return hash - } - - private struct CachedBridgeJSManifest: Decodable { - let schemaVersion: Int - let skeletonFNV64: String - let runtimeFNV64: String - } - - private func bridgeJSSkeleton(checkouts: URL) -> URL? { - var candidates: [URL] = [] - if let localSwan = ProcessInfo.processInfo.environment["ADAENGINE_SWAN_PACKAGE_PATH"], !localSwan.isEmpty { - candidates.append(URL(fileURLWithPath: localSwan).appendingPathComponent("Sources/WebGPU/Wasm/Generated/JavaScript/BridgeJS.json")) - } - candidates += [ - checkouts.appending( - components: "swan", "Sources", "WebGPU", "Wasm", "Generated", "JavaScript", "BridgeJS.json", - directoryHint: .notDirectory - ), - checkouts.appending( - components: "Swan", "Sources", "WebGPU", "Wasm", "Generated", "JavaScript", "BridgeJS.json", - directoryHint: .notDirectory - ) - ] - return candidates.first(where: { FileManager.default.fileExists(atPath: $0.path()) }) - } - - private func bridgeJSPackageDirectory(checkouts: URL) throws -> URL { - let candidates = [ - checkouts.appending( - components: "JavaScriptKit", "Plugins", "BridgeJS", - directoryHint: .isDirectory - ), - checkouts.appending( - components: "javascriptkit", "Plugins", "BridgeJS", - directoryHint: .isDirectory - ) - ] - if let bridgeJSPackage = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path()) }) { - return bridgeJSPackage - } - - let swanDirectoryCandidates = [ - checkouts.appending(components: "swan", directoryHint: .isDirectory), - checkouts.appending(components: "Swan", directoryHint: .isDirectory) - ] - let swanDirectory = swanDirectoryCandidates.first(where: { FileManager.default.fileExists(atPath: $0.path()) }) - if let swanDirectory { - let nestedBridgeJS = swanDirectory.appending( - components: ".build", "checkouts", "JavaScriptKit", "Plugins", "BridgeJS", - directoryHint: .isDirectory - ) - if FileManager.default.fileExists(atPath: nestedBridgeJS.path()) { - return nestedBridgeJS - } - } - - throw ExportError.bridgeJSPackageNotFound - } - - private func bridgeJSToolExecutable(bridgeJSPackage: URL, packageDirectory: URL) throws -> URL { - if let existing = findBridgeJSToolExecutable(in: bridgeJSPackage) { - return existing - } - - try run( - executable: "/usr/bin/env", - arguments: [ - "swift", - "build", - "--disable-sandbox", - "--package-path", - bridgeJSPackage.path(), - "--product", - "BridgeJSToolInternal" - ], - workingDirectory: packageDirectory - ) - - guard let built = findBridgeJSToolExecutable(in: bridgeJSPackage) else { - throw ExportError.bridgeJSToolNotFound - } - return built - } - - private func findBridgeJSToolExecutable(in bridgeJSPackage: URL) -> URL? { - let buildDirectory = bridgeJSPackage.appending(component: ".build", directoryHint: .isDirectory) - guard let enumerator = FileManager.default.enumerator( - at: buildDirectory, - includingPropertiesForKeys: [.isRegularFileKey, .isExecutableKey], - options: [.skipsHiddenFiles] - ) else { - return nil - } - - for case let url as URL in enumerator where url.lastPathComponent == "BridgeJSToolInternal" { - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isExecutableKey]) - if values?.isRegularFile == true, values?.isExecutable == true { - return url - } - } - return nil - } - private func serve(directory: URL) throws { - Diagnostics.remark("Serving \(directory.path()) at http://127.0.0.1:8080") + Diagnostics.remark("Serving \(directory.path(percentEncoded: false)) at http://127.0.0.1:8080") try run( executable: "/usr/bin/env", - arguments: ["python3", "-m", "http.server", "8080", "--directory", directory.path()], + arguments: ["python3", "-m", "http.server", "8080", "--directory", directory.path(percentEncoded: false)], workingDirectory: directory ) } @@ -1114,29 +824,6 @@ struct AdaWebExportPlugin: CommandPlugin { return output } - private func runBridgeJSTool(executable: URL, skeleton: URL, output: URL, workingDirectory: URL?) throws { - FileManager.default.createFile(atPath: output.path(), contents: nil) - let outputHandle = try FileHandle(forWritingTo: output) - defer { - try? outputHandle.close() - } - - let process = Process() - process.executableURL = executable - process.arguments = ["emit-js", skeleton.path()] - process.currentDirectoryURL = workingDirectory - process.standardOutput = outputHandle - process.environment = ProcessInfo.processInfo.environment.merging([ - "ADAENGINE_WEB_EXPORT": "1", - "BUILD_WASM": "1" - ]) { _, new in new } - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw ExportError.commandFailed("\(executable.path()) emit-js \(skeleton.path())", process.terminationStatus) - } - } } private struct ExportOptions { @@ -1254,40 +941,6 @@ private extension URL { } } -private extension String { - static var tintBinaryPlatform: String { - #if arch(arm64) - #if os(macOS) - return "arm64-macos" - #elseif os(Linux) - return "arm64-linux" - #elseif os(Windows) - return "arm64-windows" - #else - return "" - #endif - #else - #if os(macOS) - return "x86_64-macos" - #elseif os(Linux) - return "x86_64-linux" - #elseif os(Windows) - return "x86_64-windows" - #else - return "" - #endif - #endif - } - - static var tintBinaryName: String { - #if os(Windows) - "tint.exe" - #else - "tint" - #endif - } -} - private enum ExportError: LocalizedError, CustomStringConvertible { case help case missingTarget @@ -1297,11 +950,10 @@ private enum ExportError: LocalizedError, CustomStringConvertible { case commandFailed(String, Int32) case wasmNotFound(String, String) case javascriptKitRuntimeNotFound - case bridgeJSPackageNotFound - case bridgeJSToolNotFound - case tintNotFound(String) case unsafeScratchDirectory case browserWASIRuntimeNotFound + case sagaWebHostNotResolved + case sagaWebHostAssetMissing(String) var errorDescription: String? { description @@ -1318,6 +970,10 @@ private enum ExportError: LocalizedError, CustomStringConvertible { return "Web export requires an isolated --scratch-path; the shared .build directory cannot be used." case .browserWASIRuntimeNotFound: return "Web export is missing BrowserWASIShim/dist/index.js. Restore the bundled WASI runtime modules from the engine repository." + case .sagaWebHostNotResolved: + return "Web export requires a SAGA dependency that provides the SAGAWebHost product." + case .sagaWebHostAssetMissing(let path): + return "The resolved SAGAWebHost package is missing a browser asset at \(path). Use a SAGA revision that ships the matching browser host." case .missingTarget: return "Missing required --product argument." case .missingValue(let argument): @@ -1332,12 +988,6 @@ private enum ExportError: LocalizedError, CustomStringConvertible { return "Could not find built \(product).wasm under \(buildDirectory)." case .javascriptKitRuntimeNotFound: return "Could not find JavaScriptKit runtime.mjs under .build/checkouts/JavaScriptKit." - case .bridgeJSPackageNotFound: - return "Could not find JavaScriptKit BridgeJS package under .build/checkouts." - case .bridgeJSToolNotFound: - return "Could not find built BridgeJSToolInternal executable." - case .tintNotFound(let path): - return "Tint binary not found at \(path). Install `tint`, set TINT_EXECUTABLE, or run `python3 script/ensure_tint.py` before exporting web builds." } } } @@ -1597,6 +1247,7 @@ private func mainJS(product: String) -> String { const requiredLimits = { maxSampledTexturesPerShaderStage: Math.min(32, adapter.limits.maxSampledTexturesPerShaderStage), maxSamplersPerShaderStage: Math.min(32, adapter.limits.maxSamplersPerShaderStage), + maxColorAttachmentBytesPerSample: Math.min(48, adapter.limits.maxColorAttachmentBytesPerSample), }; updateLoader(64, "Creating WebGPU device…"); const device = await adapter.requestDevice({ requiredFeatures, requiredLimits }); @@ -1631,6 +1282,11 @@ private func mainJS(product: String) -> String { }); globalThis.__adaWebGPUAdapter = adapter; globalThis.__adaWebGPUDevice = device; + // The browser renders through SAGA. Presentation attaches to the engine canvas when its window is created. + updateLoader(66, "Opening SAGA WebGPU host…"); + const { openWebGPU } = await import("./saga-webgpu.mjs"); + globalThis.__adaSAGAHost = await openWebGPU(null, { deferCanvas: true }); + globalThis.__adaSAGADebug = new URLSearchParams(location.search).has("sagadebug"); async function makeResourcePreopenDirectories() { updateLoader(68, "Loading resources…"); diff --git a/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/BridgeJS.json b/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/BridgeJS.json new file mode 100644 index 000000000..64b79d20b --- /dev/null +++ b/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/BridgeJS.json @@ -0,0 +1,4 @@ +{ + "moduleName": "AdaWebPlayer", + "usedExternalModules": [] +} diff --git a/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/bridge-js.js b/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/bridge-js.js index ef93e9f7b..9369e65aa 100644 --- a/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/bridge-js.js +++ b/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/bridge-js.js @@ -1,304 +1,10 @@ +// Generated by JavaScriptKit 0.59.0 BridgeJSToolInternal emit-js from an empty AdaWebPlayer skeleton. // NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, // DO NOT EDIT. // // To update this file, just rebuild your project or run // `swift package bridge-js`. -export const GPUCanvasAlphaModeValues = { - Opaque: "opaque", - Premultiplied: "premultiplied", -}; - -export const GPUPrimitiveTopologyValues = { - PointList: "point-list", - LineList: "line-list", - LineStrip: "line-strip", - TriangleList: "triangle-list", - TriangleStrip: "triangle-strip", -}; - -export const GPUVertexFormatValues = { - Uint8x2: "uint8x2", - Uint8x4: "uint8x4", - Sint8x2: "sint8x2", - Sint8x4: "sint8x4", - Unorm8x2: "unorm8x2", - Unorm8x4: "unorm8x4", - Snorm8x2: "snorm8x2", - Snorm8x4: "snorm8x4", - Float16x2: "float16x2", - Float16x4: "float16x4", - Float32: "float32", - Float32x2: "float32x2", - Float32x3: "float32x3", - Float32x4: "float32x4", - Uint32: "uint32", - Uint32x2: "uint32x2", - Uint32x3: "uint32x3", - Uint32x4: "uint32x4", - Sint32: "sint32", - Sint32x2: "sint32x2", - Sint32x3: "sint32x3", - Sint32x4: "sint32x4", -}; - -export const GPUVertexStepModeValues = { - Vertex: "vertex", - Instance: "instance", -}; - -export const GPUTextureFormatValues = { - Undefined: "", - R8Unorm: "r8unorm", - R8Snorm: "r8snorm", - R8Uint: "r8uint", - R8Sint: "r8sint", - R16Unorm: "r16unorm", - R16Snorm: "r16snorm", - R16Uint: "r16uint", - R16Sint: "r16sint", - R16Float: "r16float", - RG8Unorm: "rg8unorm", - RG8Snorm: "rg8snorm", - RG8Uint: "rg8uint", - RG8Sint: "rg8sint", - R32Float: "r32float", - R32Uint: "r32uint", - R32Sint: "r32sint", - RG16Unorm: "rg16unorm", - RG16Snorm: "rg16snorm", - RG16Uint: "rg16uint", - RG16Sint: "rg16sint", - RG16Float: "rg16float", - RGBA8Unorm: "rgba8unorm", - RGBA8UnormSrgb: "rgba8unorm-srgb", - RGBA8Snorm: "rgba8snorm", - RGBA8Uint: "rgba8uint", - RGBA8Sint: "rgba8sint", - BGRA8Unorm: "bgra8unorm", - BGRA8UnormSrgb: "bgra8unorm-srgb", - RGB10A2Uint: "rgb10a2uint", - RGB10A2Unorm: "rgb10a2unorm", - RG11B10Ufloat: "rg11b10ufloat", - RGB9E5Ufloat: "rgb9e5ufloat", - RG32Float: "rg32float", - RG32Uint: "rg32uint", - RG32Sint: "rg32sint", - RGBA16Unorm: "rgba16unorm", - RGBA16Snorm: "rgba16snorm", - RGBA16Uint: "rgba16uint", - RGBA16Sint: "rgba16sint", - RGBA16Float: "rgba16float", - RGBA32Float: "rgba32float", - RGBA32Uint: "rgba32uint", - RGBA32Sint: "rgba32sint", - Stencil8: "stencil8", - Depth16Unorm: "depth16unorm", - Depth24Plus: "depth24plus", - Depth24PlusStencil8: "depth24plus-stencil8", - Depth32Float: "depth32float", - Depth32FloatStencil8: "depth32float-stencil8", - BC1RGBAUnorm: "bc1-rgba-unorm", - BC1RGBAUnormSrgb: "bc1-rgba-unorm-srgb", - BC2RGBAUnorm: "bc2-rgba-unorm", - BC2RGBAUnormSrgb: "bc2-rgba-unorm-srgb", - BC3RGBAUnorm: "bc3-rgba-unorm", - BC3RGBAUnormSrgb: "bc3-rgba-unorm-srgb", - BC4RUnorm: "bc4-r-unorm", - BC4RSnorm: "bc4-r-snorm", - BC5RGUnorm: "bc5-rg-unorm", - BC5RGSnorm: "bc5-rg-snorm", - BC6HRGBUfloat: "bc6h-rgb-ufloat", - BC6HRGBFloat: "bc6h-rgb-float", - BC7RGBAUnorm: "bc7-rgba-unorm", - BC7RGBAUnormSrgb: "bc7-rgba-unorm-srgb", - ETC2RGB8Unorm: "etc2-rgb8unorm", - ETC2RGB8UnormSrgb: "etc2-rgb8unorm-srgb", - ETC2RGB8A1Unorm: "etc2-rgb8a1unorm", - ETC2RGB8A1UnormSrgb: "etc2-rgb8a1unorm-srgb", - ETC2RGBA8Unorm: "etc2-rgba8unorm", - ETC2RGBA8UnormSrgb: "etc2-rgba8unorm-srgb", - EACR11Unorm: "eac-r11unorm", - EACR11Snorm: "eac-r11snorm", - EACRG11Unorm: "eac-rg11unorm", - EACRG11Snorm: "eac-rg11snorm", - ASTC4x4Unorm: "astc-4x4-unorm", - ASTC4x4UnormSrgb: "astc-4x4-unorm-srgb", - ASTC5x4Unorm: "astc-5x4-unorm", - ASTC5x4UnormSrgb: "astc-5x4-unorm-srgb", - ASTC5x5Unorm: "astc-5x5-unorm", - ASTC5x5UnormSrgb: "astc-5x5-unorm-srgb", - ASTC6x5Unorm: "astc-6x5-unorm", - ASTC6x5UnormSrgb: "astc-6x5-unorm-srgb", - ASTC6x6Unorm: "astc-6x6-unorm", - ASTC6x6UnormSrgb: "astc-6x6-unorm-srgb", - ASTC8x5Unorm: "astc-8x5-unorm", - ASTC8x5UnormSrgb: "astc-8x5-unorm-srgb", - ASTC8x6Unorm: "astc-8x6-unorm", - ASTC8x6UnormSrgb: "astc-8x6-unorm-srgb", - ASTC8x8Unorm: "astc-8x8-unorm", - ASTC8x8UnormSrgb: "astc-8x8-unorm-srgb", - ASTC10x5Unorm: "astc-10x5-unorm", - ASTC10x5UnormSrgb: "astc-10x5-unorm-srgb", - ASTC10x6Unorm: "astc-10x6-unorm", - ASTC10x6UnormSrgb: "astc-10x6-unorm-srgb", - ASTC10x8Unorm: "astc-10x8-unorm", - ASTC10x8UnormSrgb: "astc-10x8-unorm-srgb", - ASTC10x10Unorm: "astc-10x10-unorm", - ASTC10x10UnormSrgb: "astc-10x10-unorm-srgb", - ASTC12x10Unorm: "astc-12x10-unorm", - ASTC12x10UnormSrgb: "astc-12x10-unorm-srgb", - ASTC12x12Unorm: "astc-12x12-unorm", - ASTC12x12UnormSrgb: "astc-12x12-unorm-srgb", - Force32: "force32", -}; - -export const GPULoadOpValues = { - Load: "load", - Clear: "clear", -}; - -export const GPUStoreOpValues = { - Store: "store", - Discard: "discard", -}; - -export const GPUFrontFaceValues = { - CCW: "ccw", - CW: "cw", -}; - -export const GPUCullModeValues = { - None: "none", - Front: "front", - Back: "back", -}; - -export const GPUIndexFormatValues = { - Undefined: "undefined", - Uint16: "uint16", - Uint32: "uint32", - Force32: "force32", -}; - -export const GPUBufferBindingTypeValues = { - Uniform: "uniform", - Storage: "storage", - ReadOnlyStorage: "read-only-storage", -}; - -export const GPUTextureViewDimensionValues = { - _1D: "1d", - _2D: "2d", - _2DArray: "2d-array", - Cube: "cube", - CubeArray: "cube-array", - _3D: "3d", -}; - -export const GPUTextureSampleTypeValues = { - Float: "float", - UnfilterableFloat: "unfilterable-float", - Depth: "depth", - Sint: "sint", - Uint: "uint", -}; - -export const GPUStorageTextureAccessValues = { - WriteOnly: "write-only", - ReadOnly: "read-only", - ReadWrite: "read-write", -}; - -export const GPUSamplerBindingTypeValues = { - Filtering: "filtering", - NonFiltering: "non-filtering", - Comparison: "comparison", -}; - -export const GPUQueryTypeValues = { - Occlusion: "occlusion", - Timestamp: "timestamp", -}; - -export const GPUAddressModeValues = { - ClampToEdge: "clamp-to-edge", - RepeatMode: "repeat", - MirrorRepeat: "mirror-repeat", -}; - -export const GPUFilterModeValues = { - Nearest: "nearest", - Linear: "linear", -}; - -export const GPUMipmapFilterModeValues = { - Nearest: "nearest", - Linear: "linear", -}; - -export const GPUCompareFunctionValues = { - Never: "never", - Less: "less", - Equal: "equal", - LessEqual: "less-equal", - Greater: "greater", - NotEqual: "not-equal", - GreaterEqual: "greater-equal", - Always: "always", -}; - -export const GPUBlendFactorValues = { - Zero: "zero", - One: "one", - Src: "src", - OneMinusSrc: "one-minus-src", - SrcAlpha: "src-alpha", - OneMinusSrcAlpha: "one-minus-src-alpha", - Dst: "dst", - OneMinusDst: "one-minus-dst", - DstAlpha: "dst-alpha", - OneMinusDstAlpha: "one-minus-dst-alpha", - SrcAlphaSaturated: "src-alpha-saturated", - Constant: "constant", - OneMinusConstant: "one-minus-constant", - Src1: "src1", - OneMinusSrc1: "one-minus-src1", - Src1Alpha: "src1-alpha", - OneMinusSrc1Alpha: "one-minus-src1-alpha", -}; - -export const GPUBlendOperationValues = { - Add: "add", - Subtract: "subtract", - ReverseSubtract: "reverse-subtract", - Min: "min", - Max: "max", -}; - -export const GPUTextureAspectValues = { - All: "all", - StencilOnly: "stencil-only", - DepthOnly: "depth-only", -}; - -export const GPUTextureDimensionValues = { - _1D: "1d", - _2D: "2d", - _3D: "3d", -}; - -export const GPUStencilOperationValues = { - Keep: "keep", - Zero: "zero", - Replace: "replace", - Invert: "invert", - IncrementClamp: "increment-clamp", - DecrementClamp: "decrement-clamp", - IncrementWrap: "increment-wrap", - DecrementWrap: "decrement-wrap", -}; - export async function createInstantiator(options, swift) { let instance; let memory; @@ -316,1447 +22,16 @@ export async function createInstantiator(options, swift) { let tmpRetOptionalHeapObject; let strStack = []; let i32Stack = []; + let i64Stack = []; let f32Stack = []; let f64Stack = []; let ptrStack = []; + let taStack = []; const enumHelpers = {}; const structHelpers = {}; let _exports = null; let bjs = null; - const __bjs_createGPUBufferDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - i32Stack.push((value.usage | 0)); - i32Stack.push((value.size | 0)); - i32Stack.push(value.mappedAtCreation ? 1 : 0); - }, - lift: () => { - const bool = i32Stack.pop() !== 0; - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue, usage: int1, size: int, mappedAtCreation: bool }; - } - }); - const __bjs_createGPUShaderModuleDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const bytes1 = textEncoder.encode(value.code); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - }, - lift: () => { - const string = strStack.pop(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string1 = strStack.pop(); - optValue = string1; - } - return { label: optValue, code: string }; - } - }); - const __bjs_createGPUVertexAttributeHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.format); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - i32Stack.push((value.offset | 0)); - i32Stack.push((value.shaderLocation | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - const rawValue = strStack.pop(); - return { format: rawValue, offset: int1, shaderLocation: int }; - } - }); - const __bjs_createGPUVertexBufferLayoutHelpers = () => ({ - lower: (value) => { - i32Stack.push((value.arrayStride | 0)); - const bytes = textEncoder.encode(value.stepMode); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - for (const elem of value.attributes) { - structHelpers.GPUVertexAttribute.lower(elem); - } - i32Stack.push(value.attributes.length); - }, - lift: () => { - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const struct = structHelpers.GPUVertexAttribute.lift(); - arrayResult.push(struct); - } - arrayResult.reverse(); - const rawValue = strStack.pop(); - const int = i32Stack.pop(); - return { arrayStride: int, stepMode: rawValue, attributes: arrayResult }; - } - }); - const __bjs_createGPUPrimitiveStateHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.topology); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - const bytes1 = textEncoder.encode(value.stripIndexFormat); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - const bytes2 = textEncoder.encode(value.frontFace); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - const bytes3 = textEncoder.encode(value.cullMode); - const id3 = swift.memory.retain(bytes3); - i32Stack.push(bytes3.length); - i32Stack.push(id3); - }, - lift: () => { - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const rawValue2 = strStack.pop(); - const rawValue3 = strStack.pop(); - return { topology: rawValue3, stripIndexFormat: rawValue2, frontFace: rawValue1, cullMode: rawValue }; - } - }); - const __bjs_createGPUBlendComponentHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.operation); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - const bytes1 = textEncoder.encode(value.srcFactor); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - const bytes2 = textEncoder.encode(value.dstFactor); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - }, - lift: () => { - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const rawValue2 = strStack.pop(); - return { operation: rawValue2, srcFactor: rawValue1, dstFactor: rawValue }; - } - }); - const __bjs_createGPUBlendStateHelpers = () => ({ - lower: (value) => { - structHelpers.GPUBlendComponent.lower(value.color); - structHelpers.GPUBlendComponent.lower(value.alpha); - }, - lift: () => { - const struct = structHelpers.GPUBlendComponent.lift(); - const struct1 = structHelpers.GPUBlendComponent.lift(); - return { color: struct1, alpha: struct }; - } - }); - const __bjs_createGPUColorTargetStateHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.format); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - const isSome = value.blend != null ? 1 : 0; - if (isSome) { - structHelpers.GPUBlendState.lower(value.blend); - } - i32Stack.push(isSome); - i32Stack.push((value.writeMask | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const struct = structHelpers.GPUBlendState.lift(); - optValue = struct; - } - const rawValue = strStack.pop(); - return { format: rawValue, blend: optValue, writeMask: int }; - } - }); - const __bjs_createGPUColorHelpers = () => ({ - lower: (value) => { - f64Stack.push(value.r); - f64Stack.push(value.g); - f64Stack.push(value.b); - f64Stack.push(value.a); - }, - lift: () => { - const f64 = f64Stack.pop(); - const f641 = f64Stack.pop(); - const f642 = f64Stack.pop(); - const f643 = f64Stack.pop(); - return { r: f643, g: f642, b: f641, a: f64 }; - } - }); - const __bjs_createGPUCommandEncoderDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue }; - } - }); - const __bjs_createGPUCommandBufferDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue }; - } - }); - const __bjs_createGPURenderPassColorAttachmentHelpers = () => ({ - lower: (value) => { - let id; - if (value.view != null) { - id = swift.memory.retain(value.view); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const bytes = textEncoder.encode(value.loadOp); - const id1 = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id1); - const bytes1 = textEncoder.encode(value.storeOp); - const id2 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id2); - const isSome = value.clearValue != null ? 1 : 0; - if (isSome) { - structHelpers.GPUColor.lower(value.clearValue); - } - i32Stack.push(isSome); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const struct = structHelpers.GPUColor.lift(); - optValue = struct; - } - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { view: value, loadOp: rawValue1, storeOp: rawValue, clearValue: optValue }; - } - }); - const __bjs_createGPURenderPassDepthStencilAttachmentHelpers = () => ({ - lower: (value) => { - let id; - if (value.view != null) { - id = swift.memory.retain(value.view); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const isSome = value.depthLoadOp != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.depthLoadOp); - const id1 = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id1); - } - i32Stack.push(isSome); - const isSome1 = value.depthStoreOp != null ? 1 : 0; - if (isSome1) { - const bytes1 = textEncoder.encode(value.depthStoreOp); - const id2 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id2); - } - i32Stack.push(isSome1); - f64Stack.push(value.depthClearValue); - i32Stack.push(value.depthReadOnly ? 1 : 0); - const isSome2 = value.stencilLoadOp != null ? 1 : 0; - if (isSome2) { - const bytes2 = textEncoder.encode(value.stencilLoadOp); - const id3 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id3); - } - i32Stack.push(isSome2); - const isSome3 = value.stencilStoreOp != null ? 1 : 0; - if (isSome3) { - const bytes3 = textEncoder.encode(value.stencilStoreOp); - const id4 = swift.memory.retain(bytes3); - i32Stack.push(bytes3.length); - i32Stack.push(id4); - } - i32Stack.push(isSome3); - i32Stack.push((value.stencilClearValue | 0)); - i32Stack.push(value.stencilReadOnly ? 1 : 0); - }, - lift: () => { - const bool = i32Stack.pop() !== 0; - const int = i32Stack.pop(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const rawValue = strStack.pop(); - optValue = rawValue; - } - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const rawValue1 = strStack.pop(); - optValue1 = rawValue1; - } - const bool1 = i32Stack.pop() !== 0; - const f64 = f64Stack.pop(); - const isSome2 = i32Stack.pop(); - let optValue2; - if (isSome2 === 0) { - optValue2 = null; - } else { - const rawValue2 = strStack.pop(); - optValue2 = rawValue2; - } - const isSome3 = i32Stack.pop(); - let optValue3; - if (isSome3 === 0) { - optValue3 = null; - } else { - const rawValue3 = strStack.pop(); - optValue3 = rawValue3; - } - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { view: value, depthLoadOp: optValue3, depthStoreOp: optValue2, depthClearValue: f64, depthReadOnly: bool1, stencilLoadOp: optValue1, stencilStoreOp: optValue, stencilClearValue: int, stencilReadOnly: bool }; - } - }); - const __bjs_createGPURenderPassDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - for (const elem of value.colorAttachments) { - structHelpers.GPURenderPassColorAttachment.lower(elem); - } - i32Stack.push(value.colorAttachments.length); - const isSome1 = value.depthStencilAttachment != null ? 1 : 0; - if (isSome1) { - structHelpers.GPURenderPassDepthStencilAttachment.lower(value.depthStencilAttachment); - } - i32Stack.push(isSome1); - const isSome2 = value.timestampWrites != null ? 1 : 0; - if (isSome2) { - structHelpers.GPUPassTimestampWrites.lower(value.timestampWrites); - } - i32Stack.push(isSome2); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const struct = structHelpers.GPUPassTimestampWrites.lift(); - optValue = struct; - } - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const struct1 = structHelpers.GPURenderPassDepthStencilAttachment.lift(); - optValue1 = struct1; - } - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const struct2 = structHelpers.GPURenderPassColorAttachment.lift(); - arrayResult.push(struct2); - } - arrayResult.reverse(); - const isSome2 = i32Stack.pop(); - let optValue2; - if (isSome2 === 0) { - optValue2 = null; - } else { - const string = strStack.pop(); - optValue2 = string; - } - return { label: optValue2, colorAttachments: arrayResult, depthStencilAttachment: optValue1, timestampWrites: optValue }; - } - }); - const __bjs_createGPUComputePassDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const isSome1 = value.timestampWrites != null ? 1 : 0; - if (isSome1) { - structHelpers.GPUPassTimestampWrites.lower(value.timestampWrites); - } - i32Stack.push(isSome1); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const struct = structHelpers.GPUPassTimestampWrites.lift(); - optValue = struct; - } - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const string = strStack.pop(); - optValue1 = string; - } - return { label: optValue1, timestampWrites: optValue }; - } - }); - const __bjs_createGPUStencilFaceStateHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.compare); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - const bytes1 = textEncoder.encode(value.failOp); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - const bytes2 = textEncoder.encode(value.depthFailOp); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - const bytes3 = textEncoder.encode(value.passOp); - const id3 = swift.memory.retain(bytes3); - i32Stack.push(bytes3.length); - i32Stack.push(id3); - }, - lift: () => { - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const rawValue2 = strStack.pop(); - const rawValue3 = strStack.pop(); - return { compare: rawValue3, failOp: rawValue2, depthFailOp: rawValue1, passOp: rawValue }; - } - }); - const __bjs_createGPUDepthStencilStateHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.format); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - i32Stack.push(value.depthWriteEnabled ? 1 : 0); - const bytes1 = textEncoder.encode(value.depthCompare); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - structHelpers.GPUStencilFaceState.lower(value.stencilFront); - structHelpers.GPUStencilFaceState.lower(value.stencilBack); - i32Stack.push((value.stencilReadMask | 0)); - i32Stack.push((value.stencilWriteMask | 0)); - i32Stack.push((value.depthBias | 0)); - f64Stack.push(value.depthBiasSlopeScale); - f64Stack.push(value.depthBiasClamp); - }, - lift: () => { - const f64 = f64Stack.pop(); - const f641 = f64Stack.pop(); - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - const int2 = i32Stack.pop(); - const struct = structHelpers.GPUStencilFaceState.lift(); - const struct1 = structHelpers.GPUStencilFaceState.lift(); - const rawValue = strStack.pop(); - const bool = i32Stack.pop() !== 0; - const rawValue1 = strStack.pop(); - return { format: rawValue1, depthWriteEnabled: bool, depthCompare: rawValue, stencilFront: struct1, stencilBack: struct, stencilReadMask: int2, stencilWriteMask: int1, depthBias: int, depthBiasSlopeScale: f641, depthBiasClamp: f64 }; - } - }); - const __bjs_createGPUExtent3DHelpers = () => ({ - lower: (value) => { - i32Stack.push((value.width | 0)); - i32Stack.push((value.height | 0)); - i32Stack.push((value.depthOrArrayLayers | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - const int2 = i32Stack.pop(); - return { width: int2, height: int1, depthOrArrayLayers: int }; - } - }); - const __bjs_createGPUOrigin3DHelpers = () => ({ - lower: (value) => { - i32Stack.push((value.x | 0)); - i32Stack.push((value.y | 0)); - i32Stack.push((value.z | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - const int2 = i32Stack.pop(); - return { x: int2, y: int1, z: int }; - } - }); - const __bjs_createGPUTextureDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - structHelpers.GPUExtent3D.lower(value.size); - i32Stack.push((value.mipLevelCount | 0)); - i32Stack.push((value.sampleCount | 0)); - const bytes1 = textEncoder.encode(value.dimension); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - const bytes2 = textEncoder.encode(value.format); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - i32Stack.push((value.usage | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const int1 = i32Stack.pop(); - const int2 = i32Stack.pop(); - const struct = structHelpers.GPUExtent3D.lift(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue, size: struct, mipLevelCount: int2, sampleCount: int1, dimension: rawValue1, format: rawValue, usage: int }; - } - }); - const __bjs_createGPUTextureViewDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const isSome1 = value.format != null ? 1 : 0; - if (isSome1) { - const bytes1 = textEncoder.encode(value.format); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - } - i32Stack.push(isSome1); - const isSome2 = value.dimension != null ? 1 : 0; - if (isSome2) { - const bytes2 = textEncoder.encode(value.dimension); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - } - i32Stack.push(isSome2); - const bytes3 = textEncoder.encode(value.aspect); - const id3 = swift.memory.retain(bytes3); - i32Stack.push(bytes3.length); - i32Stack.push(id3); - i32Stack.push((value.baseMipLevel | 0)); - const isSome3 = value.mipLevelCount != null ? 1 : 0; - if (isSome3) { - i32Stack.push((value.mipLevelCount | 0)); - } - i32Stack.push(isSome3); - i32Stack.push((value.baseArrayLayer | 0)); - const isSome4 = value.arrayLayerCount != null ? 1 : 0; - if (isSome4) { - i32Stack.push((value.arrayLayerCount | 0)); - } - i32Stack.push(isSome4); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const int = i32Stack.pop(); - optValue = int; - } - const int1 = i32Stack.pop(); - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const int2 = i32Stack.pop(); - optValue1 = int2; - } - const int3 = i32Stack.pop(); - const rawValue = strStack.pop(); - const isSome2 = i32Stack.pop(); - let optValue2; - if (isSome2 === 0) { - optValue2 = null; - } else { - const rawValue1 = strStack.pop(); - optValue2 = rawValue1; - } - const isSome3 = i32Stack.pop(); - let optValue3; - if (isSome3 === 0) { - optValue3 = null; - } else { - const rawValue2 = strStack.pop(); - optValue3 = rawValue2; - } - const isSome4 = i32Stack.pop(); - let optValue4; - if (isSome4 === 0) { - optValue4 = null; - } else { - const string = strStack.pop(); - optValue4 = string; - } - return { label: optValue4, format: optValue3, dimension: optValue2, aspect: rawValue, baseMipLevel: int3, mipLevelCount: optValue1, baseArrayLayer: int1, arrayLayerCount: optValue }; - } - }); - const __bjs_createGPUSamplerDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const bytes1 = textEncoder.encode(value.addressModeU); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - const bytes2 = textEncoder.encode(value.addressModeV); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - const bytes3 = textEncoder.encode(value.addressModeW); - const id3 = swift.memory.retain(bytes3); - i32Stack.push(bytes3.length); - i32Stack.push(id3); - const bytes4 = textEncoder.encode(value.magFilter); - const id4 = swift.memory.retain(bytes4); - i32Stack.push(bytes4.length); - i32Stack.push(id4); - const bytes5 = textEncoder.encode(value.minFilter); - const id5 = swift.memory.retain(bytes5); - i32Stack.push(bytes5.length); - i32Stack.push(id5); - const bytes6 = textEncoder.encode(value.mipmapFilter); - const id6 = swift.memory.retain(bytes6); - i32Stack.push(bytes6.length); - i32Stack.push(id6); - f64Stack.push(value.lodMinClamp); - f64Stack.push(value.lodMaxClamp); - const isSome1 = value.compare != null ? 1 : 0; - if (isSome1) { - const bytes7 = textEncoder.encode(value.compare); - const id7 = swift.memory.retain(bytes7); - i32Stack.push(bytes7.length); - i32Stack.push(id7); - } - i32Stack.push(isSome1); - i32Stack.push((value.maxAnisotropy | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const rawValue = strStack.pop(); - optValue = rawValue; - } - const f64 = f64Stack.pop(); - const f641 = f64Stack.pop(); - const rawValue1 = strStack.pop(); - const rawValue2 = strStack.pop(); - const rawValue3 = strStack.pop(); - const rawValue4 = strStack.pop(); - const rawValue5 = strStack.pop(); - const rawValue6 = strStack.pop(); - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const string = strStack.pop(); - optValue1 = string; - } - return { label: optValue1, addressModeU: rawValue6, addressModeV: rawValue5, addressModeW: rawValue4, magFilter: rawValue3, minFilter: rawValue2, mipmapFilter: rawValue1, lodMinClamp: f641, lodMaxClamp: f64, compare: optValue, maxAnisotropy: int }; - } - }); - const __bjs_createGPUQuerySetDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const bytes1 = textEncoder.encode(value.type); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - i32Stack.push((value.count | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const rawValue = strStack.pop(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue, type: rawValue, count: int }; - } - }); - const __bjs_createGPUPassTimestampWritesHelpers = () => ({ - lower: (value) => { - let id; - if (value.querySet != null) { - id = swift.memory.retain(value.querySet); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - i32Stack.push((value.beginningOfPassWriteIndex | 0)); - i32Stack.push((value.endOfPassWriteIndex | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { querySet: value, beginningOfPassWriteIndex: int1, endOfPassWriteIndex: int }; - } - }); - const __bjs_createGPUBufferBindingLayoutHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.type); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - i32Stack.push(value.hasDynamicOffset ? 1 : 0); - i32Stack.push((value.minBindingSize | 0)); - }, - lift: () => { - const int = i32Stack.pop(); - const bool = i32Stack.pop() !== 0; - const rawValue = strStack.pop(); - return { type: rawValue, hasDynamicOffset: bool, minBindingSize: int }; - } - }); - const __bjs_createGPUSamplerBindingLayoutHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.type); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - }, - lift: () => { - const rawValue = strStack.pop(); - return { type: rawValue }; - } - }); - const __bjs_createGPUTextureBindingLayoutHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.sampleType); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - const bytes1 = textEncoder.encode(value.viewDimension); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - i32Stack.push(value.multisampled ? 1 : 0); - }, - lift: () => { - const bool = i32Stack.pop() !== 0; - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - return { sampleType: rawValue1, viewDimension: rawValue, multisampled: bool }; - } - }); - const __bjs_createGPUStorageTextureBindingLayoutHelpers = () => ({ - lower: (value) => { - const bytes = textEncoder.encode(value.access); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - const bytes1 = textEncoder.encode(value.format); - const id1 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id1); - const bytes2 = textEncoder.encode(value.viewDimension); - const id2 = swift.memory.retain(bytes2); - i32Stack.push(bytes2.length); - i32Stack.push(id2); - }, - lift: () => { - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const rawValue2 = strStack.pop(); - return { access: rawValue2, format: rawValue1, viewDimension: rawValue }; - } - }); - const __bjs_createGPUBindGroupLayoutEntryHelpers = () => ({ - lower: (value) => { - i32Stack.push((value.binding | 0)); - i32Stack.push((value.visibility | 0)); - const isSome = value.buffer != null ? 1 : 0; - if (isSome) { - structHelpers.GPUBufferBindingLayout.lower(value.buffer); - } - i32Stack.push(isSome); - const isSome1 = value.sampler != null ? 1 : 0; - if (isSome1) { - structHelpers.GPUSamplerBindingLayout.lower(value.sampler); - } - i32Stack.push(isSome1); - const isSome2 = value.texture != null ? 1 : 0; - if (isSome2) { - structHelpers.GPUTextureBindingLayout.lower(value.texture); - } - i32Stack.push(isSome2); - const isSome3 = value.storageTexture != null ? 1 : 0; - if (isSome3) { - structHelpers.GPUStorageTextureBindingLayout.lower(value.storageTexture); - } - i32Stack.push(isSome3); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const struct = structHelpers.GPUStorageTextureBindingLayout.lift(); - optValue = struct; - } - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const struct1 = structHelpers.GPUTextureBindingLayout.lift(); - optValue1 = struct1; - } - const isSome2 = i32Stack.pop(); - let optValue2; - if (isSome2 === 0) { - optValue2 = null; - } else { - const struct2 = structHelpers.GPUSamplerBindingLayout.lift(); - optValue2 = struct2; - } - const isSome3 = i32Stack.pop(); - let optValue3; - if (isSome3 === 0) { - optValue3 = null; - } else { - const struct3 = structHelpers.GPUBufferBindingLayout.lift(); - optValue3 = struct3; - } - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - return { binding: int1, visibility: int, buffer: optValue3, sampler: optValue2, texture: optValue1, storageTexture: optValue }; - } - }); - const __bjs_createGPUBindGroupLayoutDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - for (const elem of value.entries) { - structHelpers.GPUBindGroupLayoutEntry.lower(elem); - } - i32Stack.push(value.entries.length); - }, - lift: () => { - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const struct = structHelpers.GPUBindGroupLayoutEntry.lift(); - arrayResult.push(struct); - } - arrayResult.reverse(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue, entries: arrayResult }; - } - }); - const __bjs_createGPUMultisampleStateHelpers = () => ({ - lower: (value) => { - i32Stack.push((value.count | 0)); - i32Stack.push((value.mask | 0)); - i32Stack.push(value.alphaToCoverageEnabled ? 1 : 0); - }, - lift: () => { - const bool = i32Stack.pop() !== 0; - const int = i32Stack.pop(); - const int1 = i32Stack.pop(); - return { count: int1, mask: int, alphaToCoverageEnabled: bool }; - } - }); - const __bjs_createGPUVertexStateHelpers = () => ({ - lower: (value) => { - let id; - if (value.module != null) { - id = swift.memory.retain(value.module); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const isSome = value.entryPoint != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.entryPoint); - const id1 = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id1); - } - i32Stack.push(isSome); - for (const elem of value.buffers) { - structHelpers.GPUVertexBufferLayout.lower(elem); - } - i32Stack.push(value.buffers.length); - }, - lift: () => { - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const struct = structHelpers.GPUVertexBufferLayout.lift(); - arrayResult.push(struct); - } - arrayResult.reverse(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { module: value, entryPoint: optValue, buffers: arrayResult }; - } - }); - const __bjs_createGPUFragmentStateHelpers = () => ({ - lower: (value) => { - let id; - if (value.module != null) { - id = swift.memory.retain(value.module); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const isSome = value.entryPoint != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.entryPoint); - const id1 = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id1); - } - i32Stack.push(isSome); - for (const elem of value.targets) { - structHelpers.GPUColorTargetState.lower(elem); - } - i32Stack.push(value.targets.length); - }, - lift: () => { - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const struct = structHelpers.GPUColorTargetState.lift(); - arrayResult.push(struct); - } - arrayResult.reverse(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { module: value, entryPoint: optValue, targets: arrayResult }; - } - }); - const __bjs_createGPURenderPipelineDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const isSome1 = value.layout != null ? 1 : 0; - if (isSome1) { - const objId = swift.memory.retain(value.layout); - i32Stack.push(objId); - } - i32Stack.push(isSome1); - structHelpers.GPUVertexState.lower(value.vertex); - structHelpers.GPUPrimitiveState.lower(value.primitive); - const isSome2 = value.depthStencil != null ? 1 : 0; - if (isSome2) { - structHelpers.GPUDepthStencilState.lower(value.depthStencil); - } - i32Stack.push(isSome2); - structHelpers.GPUMultisampleState.lower(value.multisample); - const isSome3 = value.fragment != null ? 1 : 0; - if (isSome3) { - structHelpers.GPUFragmentState.lower(value.fragment); - } - i32Stack.push(isSome3); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const struct = structHelpers.GPUFragmentState.lift(); - optValue = struct; - } - const struct1 = structHelpers.GPUMultisampleState.lift(); - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const struct2 = structHelpers.GPUDepthStencilState.lift(); - optValue1 = struct2; - } - const struct3 = structHelpers.GPUPrimitiveState.lift(); - const struct4 = structHelpers.GPUVertexState.lift(); - const isSome2 = i32Stack.pop(); - let optValue2; - if (isSome2 === 0) { - optValue2 = null; - } else { - const objId = i32Stack.pop(); - const obj = swift.memory.getObject(objId); - swift.memory.release(objId); - optValue2 = obj; - } - const isSome3 = i32Stack.pop(); - let optValue3; - if (isSome3 === 0) { - optValue3 = null; - } else { - const string = strStack.pop(); - optValue3 = string; - } - return { label: optValue3, layout: optValue2, vertex: struct4, primitive: struct3, depthStencil: optValue1, multisample: struct1, fragment: optValue }; - } - }); - const __bjs_createGPUBufferBindingHelpers = () => ({ - lower: (value) => { - let id; - if (value.buffer != null) { - id = swift.memory.retain(value.buffer); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const isSome = value.offset != null ? 1 : 0; - if (isSome) { - i32Stack.push((value.offset | 0)); - } - i32Stack.push(isSome); - const isSome1 = value.size != null ? 1 : 0; - if (isSome1) { - i32Stack.push((value.size | 0)); - } - i32Stack.push(isSome1); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const int = i32Stack.pop(); - optValue = int; - } - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const int1 = i32Stack.pop(); - optValue1 = int1; - } - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { buffer: value, offset: optValue1, size: optValue }; - } - }); - const __bjs_createGPUBindGroupEntryHelpers = () => ({ - lower: (value) => { - i32Stack.push((value.binding | 0)); - structHelpers.GPUBufferBinding.lower(value.resource); - }, - lift: () => { - const struct = structHelpers.GPUBufferBinding.lift(); - const int = i32Stack.pop(); - return { binding: int, resource: struct }; - } - }); - const __bjs_createGPUBindGroupDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - let id1; - if (value.layout != null) { - id1 = swift.memory.retain(value.layout); - } else { - id1 = undefined; - } - i32Stack.push(id1 !== undefined ? id1 : 0); - for (const elem of value.entries) { - structHelpers.GPUBindGroupEntry.lower(elem); - } - i32Stack.push(value.entries.length); - }, - lift: () => { - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const struct = structHelpers.GPUBindGroupEntry.lift(); - arrayResult.push(struct); - } - arrayResult.reverse(); - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue, layout: value, entries: arrayResult }; - } - }); - const __bjs_createGPUCanvasConfigurationHelpers = () => ({ - lower: (value) => { - let id; - if (value.device != null) { - id = swift.memory.retain(value.device); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const bytes = textEncoder.encode(value.format); - const id1 = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id1); - const bytes1 = textEncoder.encode(value.alphaMode); - const id2 = swift.memory.retain(bytes1); - i32Stack.push(bytes1.length); - i32Stack.push(id2); - }, - lift: () => { - const rawValue = strStack.pop(); - const rawValue1 = strStack.pop(); - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { device: value, format: rawValue1, alphaMode: rawValue }; - } - }); - const __bjs_createGPUComputeStateHelpers = () => ({ - lower: (value) => { - let id; - if (value.module != null) { - id = swift.memory.retain(value.module); - } else { - id = undefined; - } - i32Stack.push(id !== undefined ? id : 0); - const isSome = value.entryPoint != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.entryPoint); - const id1 = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id1); - } - i32Stack.push(isSome); - }, - lift: () => { - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - const objectId = i32Stack.pop(); - let value; - if (objectId !== 0) { - value = swift.memory.getObject(objectId); - swift.memory.release(objectId); - } else { - value = null; - } - return { module: value, entryPoint: optValue }; - } - }); - const __bjs_createGPUComputePipelineDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - const isSome1 = value.layout != null ? 1 : 0; - if (isSome1) { - const objId = swift.memory.retain(value.layout); - i32Stack.push(objId); - } - i32Stack.push(isSome1); - structHelpers.GPUComputeState.lower(value.compute); - }, - lift: () => { - const struct = structHelpers.GPUComputeState.lift(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const objId = i32Stack.pop(); - const obj = swift.memory.getObject(objId); - swift.memory.release(objId); - optValue = obj; - } - const isSome1 = i32Stack.pop(); - let optValue1; - if (isSome1 === 0) { - optValue1 = null; - } else { - const string = strStack.pop(); - optValue1 = string; - } - return { label: optValue1, layout: optValue, compute: struct }; - } - }); - const __bjs_createGPUPipelineLayoutDescriptorHelpers = () => ({ - lower: (value) => { - const isSome = value.label != null ? 1 : 0; - if (isSome) { - const bytes = textEncoder.encode(value.label); - const id = swift.memory.retain(bytes); - i32Stack.push(bytes.length); - i32Stack.push(id); - } - i32Stack.push(isSome); - for (const elem of value.bindGroupLayouts) { - const objId = swift.memory.retain(elem); - i32Stack.push(objId); - } - i32Stack.push(value.bindGroupLayouts.length); - }, - lift: () => { - const arrayLen = i32Stack.pop(); - const arrayResult = []; - for (let i = 0; i < arrayLen; i++) { - const objId = i32Stack.pop(); - const obj = swift.memory.getObject(objId); - swift.memory.release(objId); - arrayResult.push(obj); - } - arrayResult.reverse(); - const isSome = i32Stack.pop(); - let optValue; - if (isSome === 0) { - optValue = null; - } else { - const string = strStack.pop(); - optValue = string; - } - return { label: optValue, bindGroupLayouts: arrayResult }; - } - }); return { /** @@ -1771,14 +46,14 @@ export async function createInstantiator(options, swift) { bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { const source = swift.memory.getObject(sourceId); swift.memory.release(sourceId); - const bytes = new Uint8Array(memory.buffer, bytesPtr); + const bytes = new Uint8Array(memory.buffer, bytesPtr >>> 0); bytes.set(source); } bjs["swift_js_make_js_string"] = function(ptr, len) { return swift.memory.retain(decodeString(ptr, len)); } bjs["swift_js_init_memory_with_result"] = function(ptr, len) { - const target = new Uint8Array(memory.buffer, ptr, len); + const target = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); target.set(tmpRetBytes); tmpRetBytes = undefined; } @@ -1819,292 +94,26 @@ export async function createInstantiator(options, swift) { bjs["swift_js_pop_pointer"] = function() { return ptrStack.pop(); } - bjs["swift_js_struct_lower_GPUBufferDescriptor"] = function(objectId) { - structHelpers.GPUBufferDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBufferDescriptor"] = function() { - const value = structHelpers.GPUBufferDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUShaderModuleDescriptor"] = function(objectId) { - structHelpers.GPUShaderModuleDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUShaderModuleDescriptor"] = function() { - const value = structHelpers.GPUShaderModuleDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUVertexAttribute"] = function(objectId) { - structHelpers.GPUVertexAttribute.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUVertexAttribute"] = function() { - const value = structHelpers.GPUVertexAttribute.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUVertexBufferLayout"] = function(objectId) { - structHelpers.GPUVertexBufferLayout.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUVertexBufferLayout"] = function() { - const value = structHelpers.GPUVertexBufferLayout.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUPrimitiveState"] = function(objectId) { - structHelpers.GPUPrimitiveState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUPrimitiveState"] = function() { - const value = structHelpers.GPUPrimitiveState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBlendComponent"] = function(objectId) { - structHelpers.GPUBlendComponent.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBlendComponent"] = function() { - const value = structHelpers.GPUBlendComponent.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBlendState"] = function(objectId) { - structHelpers.GPUBlendState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBlendState"] = function() { - const value = structHelpers.GPUBlendState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUColorTargetState"] = function(objectId) { - structHelpers.GPUColorTargetState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUColorTargetState"] = function() { - const value = structHelpers.GPUColorTargetState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUColor"] = function(objectId) { - structHelpers.GPUColor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUColor"] = function() { - const value = structHelpers.GPUColor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUCommandEncoderDescriptor"] = function(objectId) { - structHelpers.GPUCommandEncoderDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUCommandEncoderDescriptor"] = function() { - const value = structHelpers.GPUCommandEncoderDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUCommandBufferDescriptor"] = function(objectId) { - structHelpers.GPUCommandBufferDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUCommandBufferDescriptor"] = function() { - const value = structHelpers.GPUCommandBufferDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPURenderPassColorAttachment"] = function(objectId) { - structHelpers.GPURenderPassColorAttachment.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPURenderPassColorAttachment"] = function() { - const value = structHelpers.GPURenderPassColorAttachment.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPURenderPassDepthStencilAttachment"] = function(objectId) { - structHelpers.GPURenderPassDepthStencilAttachment.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPURenderPassDepthStencilAttachment"] = function() { - const value = structHelpers.GPURenderPassDepthStencilAttachment.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPURenderPassDescriptor"] = function(objectId) { - structHelpers.GPURenderPassDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPURenderPassDescriptor"] = function() { - const value = structHelpers.GPURenderPassDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUComputePassDescriptor"] = function(objectId) { - structHelpers.GPUComputePassDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUComputePassDescriptor"] = function() { - const value = structHelpers.GPUComputePassDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUStencilFaceState"] = function(objectId) { - structHelpers.GPUStencilFaceState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUStencilFaceState"] = function() { - const value = structHelpers.GPUStencilFaceState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUDepthStencilState"] = function(objectId) { - structHelpers.GPUDepthStencilState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUDepthStencilState"] = function() { - const value = structHelpers.GPUDepthStencilState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUExtent3D"] = function(objectId) { - structHelpers.GPUExtent3D.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUExtent3D"] = function() { - const value = structHelpers.GPUExtent3D.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUOrigin3D"] = function(objectId) { - structHelpers.GPUOrigin3D.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUOrigin3D"] = function() { - const value = structHelpers.GPUOrigin3D.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUTextureDescriptor"] = function(objectId) { - structHelpers.GPUTextureDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUTextureDescriptor"] = function() { - const value = structHelpers.GPUTextureDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUTextureViewDescriptor"] = function(objectId) { - structHelpers.GPUTextureViewDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUTextureViewDescriptor"] = function() { - const value = structHelpers.GPUTextureViewDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUSamplerDescriptor"] = function(objectId) { - structHelpers.GPUSamplerDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUSamplerDescriptor"] = function() { - const value = structHelpers.GPUSamplerDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUQuerySetDescriptor"] = function(objectId) { - structHelpers.GPUQuerySetDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUQuerySetDescriptor"] = function() { - const value = structHelpers.GPUQuerySetDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUPassTimestampWrites"] = function(objectId) { - structHelpers.GPUPassTimestampWrites.lower(swift.memory.getObject(objectId)); + bjs["swift_js_push_i64"] = function(v) { + i64Stack.push(v); } - bjs["swift_js_struct_lift_GPUPassTimestampWrites"] = function() { - const value = structHelpers.GPUPassTimestampWrites.lift(); - return swift.memory.retain(value); + bjs["swift_js_pop_i64"] = function() { + return i64Stack.pop(); } - bjs["swift_js_struct_lower_GPUBufferBindingLayout"] = function(objectId) { - structHelpers.GPUBufferBindingLayout.lower(swift.memory.getObject(objectId)); + const taCtors = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + bjs["swift_js_push_typed_array"] = function(kind, ptr, count) { + const Ctor = taCtors[kind]; + const byteLen = count * Ctor.BYTES_PER_ELEMENT; + const copy = memory.buffer.slice(ptr, ptr + byteLen); + taStack.push(Array.from(new Ctor(copy))); } - bjs["swift_js_struct_lift_GPUBufferBindingLayout"] = function() { - const value = structHelpers.GPUBufferBindingLayout.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUSamplerBindingLayout"] = function(objectId) { - structHelpers.GPUSamplerBindingLayout.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUSamplerBindingLayout"] = function() { - const value = structHelpers.GPUSamplerBindingLayout.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUTextureBindingLayout"] = function(objectId) { - structHelpers.GPUTextureBindingLayout.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUTextureBindingLayout"] = function() { - const value = structHelpers.GPUTextureBindingLayout.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUStorageTextureBindingLayout"] = function(objectId) { - structHelpers.GPUStorageTextureBindingLayout.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUStorageTextureBindingLayout"] = function() { - const value = structHelpers.GPUStorageTextureBindingLayout.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBindGroupLayoutEntry"] = function(objectId) { - structHelpers.GPUBindGroupLayoutEntry.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBindGroupLayoutEntry"] = function() { - const value = structHelpers.GPUBindGroupLayoutEntry.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBindGroupLayoutDescriptor"] = function(objectId) { - structHelpers.GPUBindGroupLayoutDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBindGroupLayoutDescriptor"] = function() { - const value = structHelpers.GPUBindGroupLayoutDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUMultisampleState"] = function(objectId) { - structHelpers.GPUMultisampleState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUMultisampleState"] = function() { - const value = structHelpers.GPUMultisampleState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUVertexState"] = function(objectId) { - structHelpers.GPUVertexState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUVertexState"] = function() { - const value = structHelpers.GPUVertexState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUFragmentState"] = function(objectId) { - structHelpers.GPUFragmentState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUFragmentState"] = function() { - const value = structHelpers.GPUFragmentState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPURenderPipelineDescriptor"] = function(objectId) { - structHelpers.GPURenderPipelineDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPURenderPipelineDescriptor"] = function() { - const value = structHelpers.GPURenderPipelineDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBufferBinding"] = function(objectId) { - structHelpers.GPUBufferBinding.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBufferBinding"] = function() { - const value = structHelpers.GPUBufferBinding.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBindGroupEntry"] = function(objectId) { - structHelpers.GPUBindGroupEntry.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBindGroupEntry"] = function() { - const value = structHelpers.GPUBindGroupEntry.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUBindGroupDescriptor"] = function(objectId) { - structHelpers.GPUBindGroupDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUBindGroupDescriptor"] = function() { - const value = structHelpers.GPUBindGroupDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUCanvasConfiguration"] = function(objectId) { - structHelpers.GPUCanvasConfiguration.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUCanvasConfiguration"] = function() { - const value = structHelpers.GPUCanvasConfiguration.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUComputeState"] = function(objectId) { - structHelpers.GPUComputeState.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUComputeState"] = function() { - const value = structHelpers.GPUComputeState.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUComputePipelineDescriptor"] = function(objectId) { - structHelpers.GPUComputePipelineDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUComputePipelineDescriptor"] = function() { - const value = structHelpers.GPUComputePipelineDescriptor.lift(); - return swift.memory.retain(value); - } - bjs["swift_js_struct_lower_GPUPipelineLayoutDescriptor"] = function(objectId) { - structHelpers.GPUPipelineLayoutDescriptor.lower(swift.memory.getObject(objectId)); - } - bjs["swift_js_struct_lift_GPUPipelineLayoutDescriptor"] = function() { - const value = structHelpers.GPUPipelineLayoutDescriptor.lift(); - return swift.memory.retain(value); + bjs["bjs_core_register_type_handles"] = function() {}; + const __bjs_promiseSettlers = Symbol("JavaScriptKit.promiseSettlers"); + bjs["swift_js_make_promise"] = function() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + promise[__bjs_promiseSettlers] = { resolve, reject }; + return swift.memory.retain(promise); } bjs["swift_js_return_optional_bool"] = function(isSome, value) { if (isSome === 0) { @@ -2196,641 +205,6 @@ export async function createInstantiator(options, swift) { return pointer || 0; } bjs["swift_js_closure_unregister"] = function(funcRef) {} - const WebGPUWasm = importObject["WebGPUWasm"] = importObject["WebGPUWasm"] || {}; - WebGPUWasm["bjs_GPUAdapter_label__set"] = function bjs_GPUAdapter_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUAdapter__hasFeature"] = function bjs_GPUAdapter__hasFeature(self, featureBytes, featureCount) { - try { - const string = decodeString(featureBytes, featureCount); - let ret = swift.memory.getObject(self).hasFeature(string); - return ret ? 1 : 0; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUBindGroup_label__set"] = function bjs_GPUBindGroup_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUBindGroupLayout_label__set"] = function bjs_GPUBindGroupLayout_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUBuffer__size_get"] = function bjs_GPUBuffer__size_get(self) { - try { - let ret = swift.memory.getObject(self).size; - return ret; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUBuffer__usage_get"] = function bjs_GPUBuffer__usage_get(self) { - try { - let ret = swift.memory.getObject(self).usage; - return ret; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUBuffer__mapState_get"] = function bjs_GPUBuffer__mapState_get(self) { - try { - let ret = swift.memory.getObject(self).mapState; - tmpRetBytes = textEncoder.encode(ret); - return tmpRetBytes.length; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUBuffer_label__set"] = function bjs_GPUBuffer_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUBuffer__destroy"] = function bjs_GPUBuffer__destroy(self) { - try { - swift.memory.getObject(self).destroy(); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUBuffer__mapAsync"] = function bjs_GPUBuffer__mapAsync(self, mode, offset, size) { - try { - let ret = swift.memory.getObject(self).mapAsync(mode, offset, size); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUBuffer__getMappedRange"] = function bjs_GPUBuffer__getMappedRange(self, offset, size) { - try { - let ret = swift.memory.getObject(self).getMappedRange(offset, size); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUBuffer__unmap"] = function bjs_GPUBuffer__unmap(self) { - try { - swift.memory.getObject(self).unmap(); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCanvasContext__configure"] = function bjs_GPUCanvasContext__configure(self, configuration) { - try { - const value = swift.memory.getObject(configuration); - swift.memory.release(configuration); - swift.memory.getObject(self).configure(value); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCanvasContext__getCurrentTexture"] = function bjs_GPUCanvasContext__getCurrentTexture(self) { - try { - let ret = swift.memory.getObject(self).getCurrentTexture(); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUCommandBuffer_label__set"] = function bjs_GPUCommandBuffer_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder_label__set"] = function bjs_GPUCommandEncoder_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__finish"] = function bjs_GPUCommandEncoder__finish(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).finish(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUCommandEncoder__copyBufferToBuffer"] = function bjs_GPUCommandEncoder__copyBufferToBuffer(self, source, sourceOffset, destination, destinationOffset, size) { - try { - swift.memory.getObject(self).copyBufferToBuffer(swift.memory.getObject(source), sourceOffset, swift.memory.getObject(destination), destinationOffset, size); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__copyBufferToTexture"] = function bjs_GPUCommandEncoder__copyBufferToTexture(self, source, destination, copySize) { - try { - swift.memory.getObject(self).copyBufferToTexture(swift.memory.getObject(source), swift.memory.getObject(destination), swift.memory.getObject(copySize)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__copyTextureToBuffer"] = function bjs_GPUCommandEncoder__copyTextureToBuffer(self, source, destination, copySize) { - try { - swift.memory.getObject(self).copyTextureToBuffer(swift.memory.getObject(source), swift.memory.getObject(destination), swift.memory.getObject(copySize)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__resolveQuerySet"] = function bjs_GPUCommandEncoder__resolveQuerySet(self, querySet, firstQuery, queryCount, destination, destinationOffset) { - try { - swift.memory.getObject(self).resolveQuerySet(swift.memory.getObject(querySet), firstQuery, queryCount, swift.memory.getObject(destination), destinationOffset); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__clearBuffer"] = function bjs_GPUCommandEncoder__clearBuffer(self, buffer, offset) { - try { - swift.memory.getObject(self).clearBuffer(swift.memory.getObject(buffer), offset); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__clearBufferWithSize"] = function bjs_GPUCommandEncoder__clearBufferWithSize(self, buffer, offset, size) { - try { - swift.memory.getObject(self).clearBuffer(swift.memory.getObject(buffer), offset, size); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUCommandEncoder__writeTimestamp"] = function bjs_GPUCommandEncoder__writeTimestamp(self, querySet, queryIndex) { - try { - swift.memory.getObject(self).writeTimestamp(swift.memory.getObject(querySet), queryIndex); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder_label__set"] = function bjs_GPUComputePassEncoder_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__setPipeline"] = function bjs_GPUComputePassEncoder__setPipeline(self, pipeline) { - try { - swift.memory.getObject(self).setPipeline(swift.memory.getObject(pipeline)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__setBindGroup"] = function bjs_GPUComputePassEncoder__setBindGroup(self, groupIndex, group) { - try { - swift.memory.getObject(self).setBindGroup(groupIndex, swift.memory.getObject(group)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__setBindGroupWithOffsets"] = function bjs_GPUComputePassEncoder__setBindGroupWithOffsets(self, groupIndex, group, dynamicOffsets) { - try { - swift.memory.getObject(self).setBindGroup(groupIndex, swift.memory.getObject(group), swift.memory.getObject(dynamicOffsets)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__dispatchWorkgroups"] = function bjs_GPUComputePassEncoder__dispatchWorkgroups(self, workgroupCountX, workgroupCountY, workgroupCountZ) { - try { - swift.memory.getObject(self).dispatchWorkgroups(workgroupCountX, workgroupCountY, workgroupCountZ); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__dispatchWorkgroupsIndirect"] = function bjs_GPUComputePassEncoder__dispatchWorkgroupsIndirect(self, indirectBuffer, indirectOffset) { - try { - swift.memory.getObject(self).dispatchWorkgroupsIndirect(swift.memory.getObject(indirectBuffer), indirectOffset); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__writeTimestamp"] = function bjs_GPUComputePassEncoder__writeTimestamp(self, querySet, queryIndex) { - try { - swift.memory.getObject(self).writeTimestamp(swift.memory.getObject(querySet), queryIndex); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePassEncoder__end"] = function bjs_GPUComputePassEncoder__end(self) { - try { - swift.memory.getObject(self).end(); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUComputePipeline_label__set"] = function bjs_GPUComputePipeline_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUDevice__queue_get"] = function bjs_GPUDevice__queue_get(self) { - try { - let ret = swift.memory.getObject(self).queue; - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice_label__set"] = function bjs_GPUDevice_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUDevice__createBuffer"] = function bjs_GPUDevice__createBuffer(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createBuffer(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createTexture"] = function bjs_GPUDevice__createTexture(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createTexture(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createQuerySet"] = function bjs_GPUDevice__createQuerySet(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createQuerySet(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createShaderModule"] = function bjs_GPUDevice__createShaderModule(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createShaderModule(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createCommandEncoder"] = function bjs_GPUDevice__createCommandEncoder(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createCommandEncoder(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createBindGroupRaw"] = function bjs_GPUDevice__createBindGroupRaw(self, descriptor) { - try { - let ret = swift.memory.getObject(self).createBindGroup(swift.memory.getObject(descriptor)); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createPipelineLayout"] = function bjs_GPUDevice__createPipelineLayout(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createPipelineLayout(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__createComputePipeline"] = function bjs_GPUDevice__createComputePipeline(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createComputePipeline(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUDevice__pushErrorScope"] = function bjs_GPUDevice__pushErrorScope(self, filterBytes, filterCount) { - try { - const string = decodeString(filterBytes, filterCount); - swift.memory.getObject(self).pushErrorScope(string); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUDevice__popErrorScope"] = function bjs_GPUDevice__popErrorScope(self) { - try { - let ret = swift.memory.getObject(self).popErrorScope(); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUPipelineLayout_label__set"] = function bjs_GPUPipelineLayout_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQuerySet__type_get"] = function bjs_GPUQuerySet__type_get(self) { - try { - let ret = swift.memory.getObject(self).type; - tmpRetBytes = textEncoder.encode(ret); - return tmpRetBytes.length; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQuerySet__count_get"] = function bjs_GPUQuerySet__count_get(self) { - try { - let ret = swift.memory.getObject(self).count; - return ret; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUQuerySet_label__set"] = function bjs_GPUQuerySet_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQuerySet__destroy"] = function bjs_GPUQuerySet__destroy(self) { - try { - swift.memory.getObject(self).destroy(); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQueue_label__set"] = function bjs_GPUQueue_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQueue__submit"] = function bjs_GPUQueue__submit(self, commandBuffers) { - try { - swift.memory.getObject(self).submit(swift.memory.getObject(commandBuffers)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQueue__writeBuffer"] = function bjs_GPUQueue__writeBuffer(self, buffer, bufferOffset, data) { - try { - swift.memory.getObject(self).writeBuffer(swift.memory.getObject(buffer), bufferOffset, swift.memory.getObject(data)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUQueue__writeTexture"] = function bjs_GPUQueue__writeTexture(self, destination, data, dataLayout, size) { - try { - swift.memory.getObject(self).writeTexture(swift.memory.getObject(destination), swift.memory.getObject(data), swift.memory.getObject(dataLayout), swift.memory.getObject(size)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder_label__set"] = function bjs_GPURenderPassEncoder_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__setPipeline"] = function bjs_GPURenderPassEncoder__setPipeline(self, pipeline) { - try { - swift.memory.getObject(self).setPipeline(swift.memory.getObject(pipeline)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__setVertexBuffer"] = function bjs_GPURenderPassEncoder__setVertexBuffer(self, slot, buffer, offset, size) { - try { - swift.memory.getObject(self).setVertexBuffer(slot, swift.memory.getObject(buffer), offset, size); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__setIndexBuffer"] = function bjs_GPURenderPassEncoder__setIndexBuffer(self, buffer, indexFormatBytes, indexFormatCount, offset, size) { - try { - const string = decodeString(indexFormatBytes, indexFormatCount); - swift.memory.getObject(self).setIndexBuffer(swift.memory.getObject(buffer), string, offset, size); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__draw"] = function bjs_GPURenderPassEncoder__draw(self, vertexCount, instanceCount, firstVertex, firstInstance) { - try { - swift.memory.getObject(self).draw(vertexCount, instanceCount, firstVertex, firstInstance); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__drawIndexed"] = function bjs_GPURenderPassEncoder__drawIndexed(self, indexCount, instanceCount, firstIndex, baseVertex, firstInstance) { - try { - swift.memory.getObject(self).drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__setBindGroup"] = function bjs_GPURenderPassEncoder__setBindGroup(self, groupIndex, group, dynamicOffsets) { - try { - swift.memory.getObject(self).setBindGroup(groupIndex, swift.memory.getObject(group), swift.memory.getObject(dynamicOffsets)); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__setScissorRect"] = function bjs_GPURenderPassEncoder__setScissorRect(self, x, y, width, height) { - try { - swift.memory.getObject(self).setScissorRect(x, y, width, height); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__writeTimestamp"] = function bjs_GPURenderPassEncoder__writeTimestamp(self, querySet, queryIndex) { - try { - swift.memory.getObject(self).writeTimestamp(swift.memory.getObject(querySet), queryIndex); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPassEncoder__end"] = function bjs_GPURenderPassEncoder__end(self) { - try { - swift.memory.getObject(self).end(); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPipeline_label__set"] = function bjs_GPURenderPipeline_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPURenderPipeline__getBindGroupLayout"] = function bjs_GPURenderPipeline__getBindGroupLayout(self, index) { - try { - let ret = swift.memory.getObject(self).getBindGroupLayout(index); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUSampler_label__set"] = function bjs_GPUSampler_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUShaderModule_label__set"] = function bjs_GPUShaderModule_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUTexture__format_get"] = function bjs_GPUTexture__format_get(self) { - try { - let ret = swift.memory.getObject(self).format; - tmpRetBytes = textEncoder.encode(ret); - return tmpRetBytes.length; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUTexture__width_get"] = function bjs_GPUTexture__width_get(self) { - try { - let ret = swift.memory.getObject(self).width; - return ret; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUTexture__height_get"] = function bjs_GPUTexture__height_get(self) { - try { - let ret = swift.memory.getObject(self).height; - return ret; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUTexture__depthOrArrayLayers_get"] = function bjs_GPUTexture__depthOrArrayLayers_get(self) { - try { - let ret = swift.memory.getObject(self).depthOrArrayLayers; - return ret; - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUTexture_label__set"] = function bjs_GPUTexture_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUTexture__createView"] = function bjs_GPUTexture__createView(self) { - try { - let ret = swift.memory.getObject(self).createView(); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUTexture__createViewWithDescriptor"] = function bjs_GPUTexture__createViewWithDescriptor(self, descriptor) { - try { - const value = swift.memory.getObject(descriptor); - swift.memory.release(descriptor); - let ret = swift.memory.getObject(self).createView(value); - return swift.memory.retain(ret); - } catch (error) { - setException(error); - return 0 - } - } - WebGPUWasm["bjs_GPUTexture__destroy"] = function bjs_GPUTexture__destroy(self) { - try { - swift.memory.getObject(self).destroy(); - } catch (error) { - setException(error); - } - } - WebGPUWasm["bjs_GPUTextureView_label__set"] = function bjs_GPUTextureView_label__set(self, newValueBytes, newValueCount) { - try { - const string = decodeString(newValueBytes, newValueCount); - swift.memory.getObject(self).label = string; - } catch (error) { - setException(error); - } - } }, setInstance: (i) => { instance = i; @@ -2845,155 +219,7 @@ export async function createInstantiator(options, swift) { /** @param {WebAssembly.Instance} instance */ createExports: (instance) => { const js = swift.memory.heap; - const GPUBufferDescriptorHelpers = __bjs_createGPUBufferDescriptorHelpers(); - structHelpers.GPUBufferDescriptor = GPUBufferDescriptorHelpers; - - const GPUShaderModuleDescriptorHelpers = __bjs_createGPUShaderModuleDescriptorHelpers(); - structHelpers.GPUShaderModuleDescriptor = GPUShaderModuleDescriptorHelpers; - - const GPUVertexAttributeHelpers = __bjs_createGPUVertexAttributeHelpers(); - structHelpers.GPUVertexAttribute = GPUVertexAttributeHelpers; - - const GPUVertexBufferLayoutHelpers = __bjs_createGPUVertexBufferLayoutHelpers(); - structHelpers.GPUVertexBufferLayout = GPUVertexBufferLayoutHelpers; - - const GPUPrimitiveStateHelpers = __bjs_createGPUPrimitiveStateHelpers(); - structHelpers.GPUPrimitiveState = GPUPrimitiveStateHelpers; - - const GPUBlendComponentHelpers = __bjs_createGPUBlendComponentHelpers(); - structHelpers.GPUBlendComponent = GPUBlendComponentHelpers; - - const GPUBlendStateHelpers = __bjs_createGPUBlendStateHelpers(); - structHelpers.GPUBlendState = GPUBlendStateHelpers; - - const GPUColorTargetStateHelpers = __bjs_createGPUColorTargetStateHelpers(); - structHelpers.GPUColorTargetState = GPUColorTargetStateHelpers; - - const GPUColorHelpers = __bjs_createGPUColorHelpers(); - structHelpers.GPUColor = GPUColorHelpers; - - const GPUCommandEncoderDescriptorHelpers = __bjs_createGPUCommandEncoderDescriptorHelpers(); - structHelpers.GPUCommandEncoderDescriptor = GPUCommandEncoderDescriptorHelpers; - - const GPUCommandBufferDescriptorHelpers = __bjs_createGPUCommandBufferDescriptorHelpers(); - structHelpers.GPUCommandBufferDescriptor = GPUCommandBufferDescriptorHelpers; - - const GPURenderPassColorAttachmentHelpers = __bjs_createGPURenderPassColorAttachmentHelpers(); - structHelpers.GPURenderPassColorAttachment = GPURenderPassColorAttachmentHelpers; - - const GPURenderPassDepthStencilAttachmentHelpers = __bjs_createGPURenderPassDepthStencilAttachmentHelpers(); - structHelpers.GPURenderPassDepthStencilAttachment = GPURenderPassDepthStencilAttachmentHelpers; - - const GPURenderPassDescriptorHelpers = __bjs_createGPURenderPassDescriptorHelpers(); - structHelpers.GPURenderPassDescriptor = GPURenderPassDescriptorHelpers; - - const GPUComputePassDescriptorHelpers = __bjs_createGPUComputePassDescriptorHelpers(); - structHelpers.GPUComputePassDescriptor = GPUComputePassDescriptorHelpers; - - const GPUStencilFaceStateHelpers = __bjs_createGPUStencilFaceStateHelpers(); - structHelpers.GPUStencilFaceState = GPUStencilFaceStateHelpers; - - const GPUDepthStencilStateHelpers = __bjs_createGPUDepthStencilStateHelpers(); - structHelpers.GPUDepthStencilState = GPUDepthStencilStateHelpers; - - const GPUExtent3DHelpers = __bjs_createGPUExtent3DHelpers(); - structHelpers.GPUExtent3D = GPUExtent3DHelpers; - - const GPUOrigin3DHelpers = __bjs_createGPUOrigin3DHelpers(); - structHelpers.GPUOrigin3D = GPUOrigin3DHelpers; - - const GPUTextureDescriptorHelpers = __bjs_createGPUTextureDescriptorHelpers(); - structHelpers.GPUTextureDescriptor = GPUTextureDescriptorHelpers; - - const GPUTextureViewDescriptorHelpers = __bjs_createGPUTextureViewDescriptorHelpers(); - structHelpers.GPUTextureViewDescriptor = GPUTextureViewDescriptorHelpers; - - const GPUSamplerDescriptorHelpers = __bjs_createGPUSamplerDescriptorHelpers(); - structHelpers.GPUSamplerDescriptor = GPUSamplerDescriptorHelpers; - - const GPUQuerySetDescriptorHelpers = __bjs_createGPUQuerySetDescriptorHelpers(); - structHelpers.GPUQuerySetDescriptor = GPUQuerySetDescriptorHelpers; - - const GPUPassTimestampWritesHelpers = __bjs_createGPUPassTimestampWritesHelpers(); - structHelpers.GPUPassTimestampWrites = GPUPassTimestampWritesHelpers; - - const GPUBufferBindingLayoutHelpers = __bjs_createGPUBufferBindingLayoutHelpers(); - structHelpers.GPUBufferBindingLayout = GPUBufferBindingLayoutHelpers; - - const GPUSamplerBindingLayoutHelpers = __bjs_createGPUSamplerBindingLayoutHelpers(); - structHelpers.GPUSamplerBindingLayout = GPUSamplerBindingLayoutHelpers; - - const GPUTextureBindingLayoutHelpers = __bjs_createGPUTextureBindingLayoutHelpers(); - structHelpers.GPUTextureBindingLayout = GPUTextureBindingLayoutHelpers; - - const GPUStorageTextureBindingLayoutHelpers = __bjs_createGPUStorageTextureBindingLayoutHelpers(); - structHelpers.GPUStorageTextureBindingLayout = GPUStorageTextureBindingLayoutHelpers; - - const GPUBindGroupLayoutEntryHelpers = __bjs_createGPUBindGroupLayoutEntryHelpers(); - structHelpers.GPUBindGroupLayoutEntry = GPUBindGroupLayoutEntryHelpers; - - const GPUBindGroupLayoutDescriptorHelpers = __bjs_createGPUBindGroupLayoutDescriptorHelpers(); - structHelpers.GPUBindGroupLayoutDescriptor = GPUBindGroupLayoutDescriptorHelpers; - - const GPUMultisampleStateHelpers = __bjs_createGPUMultisampleStateHelpers(); - structHelpers.GPUMultisampleState = GPUMultisampleStateHelpers; - - const GPUVertexStateHelpers = __bjs_createGPUVertexStateHelpers(); - structHelpers.GPUVertexState = GPUVertexStateHelpers; - - const GPUFragmentStateHelpers = __bjs_createGPUFragmentStateHelpers(); - structHelpers.GPUFragmentState = GPUFragmentStateHelpers; - - const GPURenderPipelineDescriptorHelpers = __bjs_createGPURenderPipelineDescriptorHelpers(); - structHelpers.GPURenderPipelineDescriptor = GPURenderPipelineDescriptorHelpers; - - const GPUBufferBindingHelpers = __bjs_createGPUBufferBindingHelpers(); - structHelpers.GPUBufferBinding = GPUBufferBindingHelpers; - - const GPUBindGroupEntryHelpers = __bjs_createGPUBindGroupEntryHelpers(); - structHelpers.GPUBindGroupEntry = GPUBindGroupEntryHelpers; - - const GPUBindGroupDescriptorHelpers = __bjs_createGPUBindGroupDescriptorHelpers(); - structHelpers.GPUBindGroupDescriptor = GPUBindGroupDescriptorHelpers; - - const GPUCanvasConfigurationHelpers = __bjs_createGPUCanvasConfigurationHelpers(); - structHelpers.GPUCanvasConfiguration = GPUCanvasConfigurationHelpers; - - const GPUComputeStateHelpers = __bjs_createGPUComputeStateHelpers(); - structHelpers.GPUComputeState = GPUComputeStateHelpers; - - const GPUComputePipelineDescriptorHelpers = __bjs_createGPUComputePipelineDescriptorHelpers(); - structHelpers.GPUComputePipelineDescriptor = GPUComputePipelineDescriptorHelpers; - - const GPUPipelineLayoutDescriptorHelpers = __bjs_createGPUPipelineLayoutDescriptorHelpers(); - structHelpers.GPUPipelineLayoutDescriptor = GPUPipelineLayoutDescriptorHelpers; - const exports = { - GPUCanvasAlphaMode: GPUCanvasAlphaModeValues, - GPUPrimitiveTopology: GPUPrimitiveTopologyValues, - GPUVertexFormat: GPUVertexFormatValues, - GPUVertexStepMode: GPUVertexStepModeValues, - GPUTextureFormat: GPUTextureFormatValues, - GPULoadOp: GPULoadOpValues, - GPUStoreOp: GPUStoreOpValues, - GPUFrontFace: GPUFrontFaceValues, - GPUCullMode: GPUCullModeValues, - GPUIndexFormat: GPUIndexFormatValues, - GPUBufferBindingType: GPUBufferBindingTypeValues, - GPUTextureViewDimension: GPUTextureViewDimensionValues, - GPUTextureSampleType: GPUTextureSampleTypeValues, - GPUStorageTextureAccess: GPUStorageTextureAccessValues, - GPUSamplerBindingType: GPUSamplerBindingTypeValues, - GPUQueryType: GPUQueryTypeValues, - GPUAddressMode: GPUAddressModeValues, - GPUFilterMode: GPUFilterModeValues, - GPUMipmapFilterMode: GPUMipmapFilterModeValues, - GPUCompareFunction: GPUCompareFunctionValues, - GPUBlendFactor: GPUBlendFactorValues, - GPUBlendOperation: GPUBlendOperationValues, - GPUTextureAspect: GPUTextureAspectValues, - GPUTextureDimension: GPUTextureDimensionValues, - GPUStencilOperation: GPUStencilOperationValues, }; _exports = exports; return exports; diff --git a/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/manifest.json b/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/manifest.json deleted file mode 100644 index a0eb8aa76..000000000 --- a/Plugins/AdaWebExportPlugin/Runtime/BridgeJSCache/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schemaVersion": 1, - "skeletonFNV64": "02dd0c9c782d36f9", - "runtimeFNV64": "9c849c60fc5541ab" -} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/FullScreenShader.wesl b/Sources/AdaCorePipelines/Assets/Shaders/FullScreenShader.wesl new file mode 100644 index 000000000..3d49c3c39 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/FullScreenShader.wesl @@ -0,0 +1,14 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +@vertex +fn fullscreen_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +@group(0) @binding(0) var image: texture_2d; +@group(0) @binding(1) var imageSampler: sampler; + +@fragment +fn fullscreen_fragment(input: FullscreenVertex) -> @location(0) vec4f { + return textureSample(image, imageSampler, input.uv); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/auto_exposure_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/auto_exposure_3d.wesl new file mode 100644 index 000000000..0adffdedd --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/auto_exposure_3d.wesl @@ -0,0 +1,70 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct AutoExposureUniform { + // Mode, delta time, and adaptation speeds. + parameters: vec4f, + // Minimum EV, maximum EV, and reset. + limits: vec4f, +} + +@group(0) @binding(0) var sourceImage: texture_2d; +@group(0) @binding(1) var auxiliaryImage: texture_2d; +@group(0) @binding(2) var nearestSampler: sampler; +@group(0) @binding(3) var linearSampler: sampler; +@group(0) @binding(4) var exposure: AutoExposureUniform; + +const METER_SIZE: f32 = 64.0; + +@vertex +fn auto_exposure_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +// Accumulate coverage-weighted log luminance over a 4 × 4 grid. +fn meter(uv: vec2f) -> vec4f { + let origin = uv - vec2f(0.5 / METER_SIZE); + var sum = vec2f(0.0); + for (var y = 0; y < 4; y++) { + for (var x = 0; x < 4; x++) { + let sampleUV = origin + (vec2f(f32(x), f32(y)) + 0.5) / 4.0 / METER_SIZE; + let normal = textureSampleLevel(auxiliaryImage, nearestSampler, sampleUV, 0.0).xyz; + let color = max(textureSampleLevel(sourceImage, nearestSampler, sampleUV, 0.0).rgb, vec3f(0.0)); + let luminance = clamp(dot(color, vec3f(0.2126, 0.7152, 0.0722)), 0.0, 65000.0); + // Exclude pixels without geometry and exact black, keeping genuinely dim surfaces. + let metered = select(0.0, 1.0, length(normal) > 0.1 && luminance > 0.0); + sum += vec2f(log2(max(luminance, 0.000001)), 1.0) * metered; + } + } + return vec4f(sum / 16.0, 0.0, 1.0); +} + +fn adapt() -> vec4f { + let sums = textureSampleLevel(sourceImage, linearSampler, vec2f(0.5), 0.0); + let previous = textureSampleLevel(auxiliaryImage, nearestSampler, vec2f(0.5), 0.0); + let coverage = sums.y; + let averageLog = sums.x / max(coverage, 0.0005); + let desired = clamp(log2(0.18) - averageLog, exposure.limits.x, exposure.limits.y); + let initialized = previous.w > 0.5 && exposure.limits.z < 0.5; + // Start neutral and keep the previous state inside the active limits. + let base = clamp(select(0.0, previous.x + previous.y, initialized), exposure.limits.x, exposure.limits.y); + var ev = base; + if coverage > 0.0005 { + let speed = select(exposure.parameters.w, exposure.parameters.z, desired > base); + let blend = select(1.0 - exp(-exposure.parameters.y * speed), 1.0, speed >= 1000000.0); + ev = mix(base, desired, blend); + } + // Store the half-precision rounding residual so small adaptation steps accumulate. + let high = unpack2x16float(pack2x16float(vec2f(ev, 0.0))).x; + return vec4f(high, ev - high, coverage, 1.0); +} + +@fragment +fn auto_exposure_fragment(input: FullscreenVertex) -> @location(0) vec4f { + if exposure.parameters.x < 0.5 { + return meter(input.uv); + } + if exposure.parameters.x < 1.5 { + return textureSampleLevel(sourceImage, linearSampler, input.uv, 0.0); + } + return adapt(); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/bloom_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/bloom_3d.wesl new file mode 100644 index 000000000..87a60a535 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/bloom_3d.wesl @@ -0,0 +1,64 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct BloomUniform { + parameters: vec4f, + texel: vec4f, +} + +@group(0) @binding(0) var sourceImage: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var exposureImage: texture_2d; +@group(0) @binding(4) var bloom: BloomUniform; + +@vertex +fn bloom_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn fetch(uv: vec2f) -> vec3f { + return textureSampleLevel(sourceImage, sourceSampler, uv, 0.0).rgb; +} + +fn exposureScale() -> f32 { + var scale = bloom.texel.z; + if bloom.texel.w > 0.5 { + let state = textureSampleLevel(exposureImage, sourceSampler, vec2f(0.5), 0.0).rg; + scale *= exp2(state.x + state.y); + } + return scale; +} + +fn prefilter(color: vec3f, scale: f32) -> vec3f { + let exposed = clamp(color * scale, vec3f(0.0), vec3f(65000.0)); + let brightness = max(exposed.r, max(exposed.g, exposed.b)); + let knee = max(bloom.parameters.z, 0.0001); + let soft = clamp(brightness - bloom.parameters.y + knee, 0.0, 2.0 * knee); + let contribution = max(soft * soft / (4.0 * knee + 0.0001), brightness - bloom.parameters.y) / max(brightness, 0.0001); + return exposed * contribution; +} + +@fragment +fn bloom_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let uv = input.uv; + let texel = bloom.texel.xy; + let leftDown = vec2f(-texel.x, -texel.y); + let rightDown = vec2f(texel.x, -texel.y); + let leftUp = vec2f(-texel.x, texel.y); + let rightUp = texel; + if bloom.parameters.x < 0.5 { + let scale = exposureScale(); + let result = prefilter(fetch(uv), scale) * 4.0 + + prefilter(fetch(uv + leftDown), scale) + prefilter(fetch(uv + rightDown), scale) + + prefilter(fetch(uv + leftUp), scale) + prefilter(fetch(uv + rightUp), scale); + return vec4f(result / 8.0, 1.0); + } + if bloom.parameters.x < 1.5 { + let result = fetch(uv) * 4.0 + fetch(uv + leftDown) + fetch(uv + rightDown) + fetch(uv + leftUp) + fetch(uv + rightUp); + return vec4f(result / 8.0, 1.0); + } + let result = fetch(uv + vec2f(-texel.x * 2.0, 0.0)) + fetch(uv + leftUp) * 2.0 + + fetch(uv + vec2f(0.0, texel.y * 2.0)) + fetch(uv + rightUp) * 2.0 + + fetch(uv + vec2f(texel.x * 2.0, 0.0)) + fetch(uv + rightDown) * 2.0 + + fetch(uv + vec2f(0.0, -texel.y * 2.0)) + fetch(uv + leftDown) * 2.0; + return vec4f(result / 12.0 * bloom.parameters.w, 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/camera_effects_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/camera_effects_3d.wesl new file mode 100644 index 000000000..77dc6e634 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/camera_effects_3d.wesl @@ -0,0 +1,135 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; +import package::tonemapping::ditherDisplay; + +struct CameraEffectsUniform { + lens: vec4f, + blur: vec4f, + size: vec4f, + rain: vec4f, +} + +@group(0) @binding(0) var colorImage: texture_2d; +@group(0) @binding(1) var motionImage: texture_2d; +@group(0) @binding(2) var positionImage: texture_2d; +@group(0) @binding(3) var linearSampler: sampler; +@group(0) @binding(4) var nearestSampler: sampler; +@group(0) @binding(5) var effects: CameraEffectsUniform; + +@vertex +fn camera_effects_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn bounded(uv: vec2f) -> vec2f { + return clamp(uv, effects.size.xy * 0.5, vec2f(1.0) - effects.size.xy * 0.5); +} + +fn colorAt(uv: vec2f) -> vec3f { + return textureSampleLevel(colorImage, linearSampler, bounded(uv), 0.0).rgb; +} + +fn depthAt(uv: vec2f) -> f32 { + let position = textureSampleLevel(positionImage, nearestSampler, bounded(uv), 0.0).xyz; + return select(max(position.z, 0.001), 100000.0, length(position) < 0.0001); +} + +fn motionBlur(uv: vec2f, center: vec3f) -> vec3f { + if effects.blur.x <= 0.0 { + return center; + } + var velocity = textureSampleLevel(motionImage, linearSampler, bounded(uv), 0.0).xy * effects.blur.x; + let pixels = length(velocity / effects.size.xy); + if pixels < 0.5 { + return center; + } + velocity *= min(1.0, effects.blur.y / max(pixels, 0.001)); + let depth = depthAt(uv); + var sum = center; + var weights = 1.0; + let count = i32(effects.blur.z); + for (var i = 0; i < 16; i++) { + if i >= count { + break; + } + let t = (f32(i) + 0.5) / f32(count) - 0.5; + let sampleUV = uv + velocity * t; + // Reject samples from unrelated depth layers. + let difference = abs(depthAt(sampleUV) - depth); + let weight = (1.0 - abs(t)) * (1.0 - smoothstep(max(0.15, depth * 0.08), max(0.3, depth * 0.2), difference)); + sum += colorAt(sampleUV) * weight; + weights += weight; + } + return sum / weights; +} + +fn dropHash(p: vec2f) -> f32 { + return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453); +} + +// Refraction samples the finished scene before overlays and UI. +fn rainOnLens(uv: vec2f, original: vec3f) -> vec3f { + if effects.rain.x <= 0.0001 { + return original; + } + let grid = vec2f(12.0 * effects.rain.z, 12.0); + let at = uv * grid; + let root = floor(at); + var normal = vec2f(0.0); + var bead = 0.0; + var rim = 0.0; + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + let cell = root + vec2f(f32(x), f32(y)); + let seed = dropHash(cell); + if seed > effects.rain.x * 0.82 { + continue; + } + let phase = fract(effects.rain.y * (0.15 + seed * 0.15) + dropHash(cell + 17.0)); + let center = cell + vec2f(0.2 + 0.6 * dropHash(cell + 7.0), 0.1 + phase * 0.85); + let delta = at - center; + let radius = 0.11 + 0.12 * dropHash(cell + 31.0); + let r = length(delta / vec2f(1.0, 1.3)); + let life = smoothstep(0.0, 0.06, phase) * (1.0 - smoothstep(0.85, 1.0, phase)); + let mask = (1.0 - smoothstep(radius * 0.72, radius, r)) * life; + normal += delta * mask / max(radius, 0.01); + bead = max(bead, mask); + rim += exp(-abs(r - radius * 0.8) * 100.0) * life; + } + } + let refracted = colorAt(uv + normal / grid * 0.16); + let water = refracted * (1.0 - 0.12 * bead) + vec3f(0.22, 0.25, 0.28) * min(rim, 1.0) * 0.4; + return mix(original, water, clamp(bead + rim * 0.3, 0.0, 1.0)); +} + +@fragment +fn camera_effects_fragment(input: FullscreenVertex) -> @location(0) vec4f { + if effects.blur.w > 0.5 { + return vec4f(colorAt(input.uv), 1.0); + } + let p = input.uv * 2.0 - 1.0; + let radius2 = dot(p, p); + // Overscan keeps distorted corner samples inside the source. + let uv = 0.5 + p * (1.0 + effects.lens.x * radius2) / (1.0 + 2.0 * effects.lens.x) * 0.5; + let raw = colorAt(uv); + var color = motionBlur(uv, raw); + let edge = smoothstep(0.3, 1.8, radius2); + if effects.lens.y > 0.0 && edge > 0.0 { + let shift = p * effects.size.xy * effects.lens.y * edge; + color.r += colorAt(uv + shift).r - raw.r; + color.b += colorAt(uv - shift).b - raw.b; + } + if effects.lens.z > 0.0 && edge > 0.0 { + let stepUV = effects.size.xy * effects.lens.z * edge; + let horizontal = vec2f(stepUV.x, 0.0); + let vertical = vec2f(0.0, stepUV.y); + let soft = (colorAt(uv + horizontal) + colorAt(uv - horizontal) + colorAt(uv + vertical) + colorAt(uv - vertical)) * 0.25; + color = mix(color, soft, edge * 0.65); + } + if effects.lens.w > 0.0 { + let seed = dot(floor(input.position.xy), vec2f(12.9898, 78.233)) + effects.size.z * 1.618; + let noise = fract(sin(seed) * 43758.5453) - 0.5; + color += vec3f(noise * effects.lens.w); + } + color = rainOnLens(input.uv, color); + return vec4f(ditherDisplay(color, input.position.xy), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/circle.wesl b/Sources/AdaCorePipelines/Assets/Shaders/circle.wesl new file mode 100644 index 000000000..d03149668 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/circle.wesl @@ -0,0 +1,32 @@ +import ada_engine::view::projectPosition; + +struct CircleVertex { + @builtin(position) position: vec4f, + @location(0) localPosition: vec2f, + @location(1) thickness: f32, + @location(2) fade: f32, + @location(3) color: vec4f, +} + +@vertex +fn circle_vertex( + @location(0) position: vec3f, + @location(1) localPosition: vec2f, + @location(2) thickness: f32, + @location(3) fade: f32, + @location(4) color: vec4f, +) -> CircleVertex { + return CircleVertex(projectPosition(vec4f(position, 1.0)), localPosition, thickness, fade, color); +} + +@fragment +fn circle_fragment(input: CircleVertex) -> @location(0) vec4f { + let distance = sqrt(dot(input.localPosition, input.localPosition)); + let innerEdge = 1.0 - input.thickness; + if distance > 1.0 || distance < innerEdge - input.fade { + discard; + } + let outerAlpha = 1.0 - smoothstep(1.0 - input.fade, 1.0, distance); + let innerAlpha = smoothstep(innerEdge - input.fade, innerEdge, distance); + return vec4f(input.color.rgb, input.color.a * (outerAlpha * innerAlpha)); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/color_grading.wgsl b/Sources/AdaCorePipelines/Assets/Shaders/color_grading.wgsl new file mode 100644 index 000000000..e19c42277 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/color_grading.wgsl @@ -0,0 +1,39 @@ +fn sampleLutTile(lut: texture_2d, lutSampler: sampler, color: vec3f, tile: f32, size: f32) -> vec3f { + let texel = vec2f(1.0 / (size * size), 1.0 / size); + let inTile = color.rg * (size - 1.0) + 0.5; + return textureSampleLevel(lut, lutSampler, vec2f(tile * size + inTile.x, inTile.y) * texel, 0.0).rgb; +} + +fn applyLut(lut: texture_2d, lutSampler: sampler, color: vec3f, intensity: f32) -> vec3f { + if intensity <= 0.0 { + return color; + } + let size = f32(textureDimensions(lut, 0).y); + let clamped = clamp(color, vec3f(0.0), vec3f(1.0)); + let blue = clamped.b * (size - 1.0); + let lower = floor(blue); + let upper = min(lower + 1.0, size - 1.0); + let graded = mix( + sampleLutTile(lut, lutSampler, clamped, lower, size), + sampleLutTile(lut, lutSampler, clamped, upper, size), + blue - lower, + ); + return mix(color, graded, intensity); +} + +fn gradeLinear(input: vec3f, grading: vec4f, vignette: vec4f, uv: vec2f) -> vec3f { + var color = input; + if grading.y != 1.0 { + let pivot = 0.18; + color = pivot * pow(max(color, vec3f(0.0)) / pivot, vec3f(grading.y)); + } + if grading.x != 1.0 { + let luma = dot(color, vec3f(0.2126, 0.7152, 0.0722)); + color = max(mix(vec3f(luma), color, grading.x), vec3f(0.0)); + } + if grading.z > 0.0 { + let corner = length((uv - 0.5) * 2.0) / 1.41421356; + color *= 1.0 - grading.z * smoothstep(grading.w, grading.w + max(vignette.x, 0.01), corner); + } + return color; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/debug_view_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/debug_view_3d.wesl new file mode 100644 index 000000000..087ef1a4c --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/debug_view_3d.wesl @@ -0,0 +1,60 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct DebugViewUniform { + parameters: vec4f, +} + +@group(0) @binding(0) var normalImage: texture_2d; +@group(0) @binding(1) var positionImage: texture_2d; +@group(0) @binding(2) var occlusionImage: texture_2d; +@group(0) @binding(3) var indirectImage: texture_2d; +@group(0) @binding(4) var nearestSampler: sampler; +@group(0) @binding(5) var debugView: DebugViewUniform; +@group(0) @binding(6) var sceneColor: texture_2d; + +@vertex +fn debug_view_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn sampleAt(image: texture_2d, uv: vec2f) -> vec4f { + return textureSampleLevel(image, nearestSampler, uv, 0.0); +} + +@fragment +fn debug_view_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let uv = input.uv; + let normalRoughness = sampleAt(normalImage, uv); + let positionMetallic = sampleAt(positionImage, uv); + // A zero normal marks pixels without geometry. + let covered = length(normalRoughness.xyz) > 0.1; + let mode = debugView.parameters.x; + var color = vec3f(0.04); + if mode < 1.5 { + if covered { + color = normalize(normalRoughness.xyz) * 0.5 + 0.5; + } + } else if mode < 2.5 { + if covered { + color = vec3f(1.0 - exp(-positionMetallic.z * debugView.parameters.y)); + } + } else if mode < 3.5 { + if covered { + color = vec3f(normalRoughness.w); + } + } else if mode < 4.5 { + if covered { + color = vec3f(positionMetallic.w); + } + } else if mode < 5.5 { + color = vec3f(sampleAt(occlusionImage, uv).r); + } else if mode < 6.5 { + if covered { + color = vec3f(1.0) - exp(-sampleAt(indirectImage, uv).rgb * 24.0); + } + } else if covered { + // Albedo, cascades, and light count are display-ready in scene color. + color = clamp(sampleAt(sceneColor, uv).rgb, vec3f(0.0), vec3f(1.0)); + } + return vec4f(color, 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/depth_of_field_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/depth_of_field_3d.wesl new file mode 100644 index 000000000..93fd07904 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/depth_of_field_3d.wesl @@ -0,0 +1,58 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct DepthOfFieldUniform { + parameters: vec4f, + size: vec4f, +} + +@group(0) @binding(0) var colorImage: texture_2d; +@group(0) @binding(1) var positionImage: texture_2d; +@group(0) @binding(2) var linearSampler: sampler; +@group(0) @binding(3) var nearestSampler: sampler; +@group(0) @binding(4) var dof: DepthOfFieldUniform; + +const SAMPLE_COUNT: i32 = 24; +const GOLDEN_ANGLE: f32 = 2.39996323; + +@vertex +fn depth_of_field_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn circleOfConfusion(uv: vec2f) -> f32 { + let position = textureSampleLevel(positionImage, nearestSampler, uv, 0.0).xyz; + let depth = select(max(position.z, 0.001), 100000.0, length(position) < 0.0001); + return clamp(dof.parameters.z * abs(depth - dof.parameters.y) / depth * dof.parameters.w, 0.0, dof.parameters.w); +} + +fn colorAt(uv: vec2f) -> vec3f { + return textureSampleLevel(colorImage, linearSampler, uv, 0.0).rgb; +} + +// A neighbor contributes only as far as its own circle of confusion reaches. +fn gather(uv: vec2f) -> vec4f { + let center = colorAt(uv); + let radius = circleOfConfusion(uv); + if radius < 0.5 { + return vec4f(center, 1.0); + } + var sum = center; + var weightSum = 1.0; + for (var index = 0; index < SAMPLE_COUNT; index++) { + let distanceInPixels = sqrt((f32(index) + 0.5) / f32(SAMPLE_COUNT)) * radius; + let angle = f32(index) * GOLDEN_ANGLE; + let sampleUV = uv + vec2f(cos(angle), sin(angle)) * distanceInPixels * dof.size.xy; + let weight = clamp(circleOfConfusion(sampleUV) / max(distanceInPixels, 1.0), 0.0, 1.0); + sum += colorAt(sampleUV) * weight; + weightSum += weight; + } + return vec4f(sum / weightSum, 1.0); +} + +@fragment +fn depth_of_field_fragment(input: FullscreenVertex) -> @location(0) vec4f { + if dof.parameters.x < 0.5 { + return gather(input.uv); + } + return vec4f(textureSampleLevel(colorImage, nearestSampler, input.uv, 0.0).rgb, 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.wesl new file mode 100644 index 000000000..7e47cf49d --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/directional_shadow_3d.wesl @@ -0,0 +1,88 @@ +import package::mesh_visibility::{surface, meshOrientation, isVisibleFace, isVisibleFade, baseColorAlpha}; +@if(ADA_MORPH) import package::mesh_morph::morphPosition; + +// Shader Graph materials (ADA_SHADER_GRAPH) receive the generated graph source at this marker line. +// ADA_GRAPH_SOURCE_MARKER + +struct ShadowInput { + @location(0) position: vec3f, + @location(2) uv0: vec2f, + @location(3) vertexColor: vec4f, + @location(5) model0: vec4f, + @location(6) model1: vec4f, + @location(7) model2: vec4f, + @location(8) model3: vec4f, + @location(9) color: vec4f, + @location(12) flags: vec4f, + @location(13) joints: vec4f, + @location(14) weights: vec4f, + @location(15) uv1: vec2f, + @if(ADA_MORPH) @builtin(vertex_index) vertexIndex: u32, +} + +struct ShadowVertex { + @builtin(position) position: vec4f, + @location(0) uv0: vec2f, + @location(1) uv1: vec2f, + @location(2) alpha: f32, + @location(3) tileBounds: vec4f, + @location(4) fade: f32, + @location(5) @interpolate(flat) orientation: f32, +} + +struct ShadowView { + viewProjection: mat4x4f, + tileBounds: vec4f, +} + +struct SkinningUniform { + matrices: array, +} + +@group(0) @binding(2) var shadowView: ShadowView; +@group(0) @binding(14) var skinning: SkinningUniform; + +fn skinTransform(input: ShadowInput) -> mat4x4f { + if input.joints.x < 0.0 { + return mat4x4f(vec4f(1, 0, 0, 0), vec4f(0, 1, 0, 0), vec4f(0, 0, 1, 0), vec4f(0, 0, 0, 1)); + } + let jointBase = i32(max(0.0, -input.flags.y)); + return skinning.matrices[jointBase + i32(input.joints.x)] * input.weights.x + + skinning.matrices[jointBase + i32(input.joints.y)] * input.weights.y + + skinning.matrices[jointBase + i32(input.joints.z)] * input.weights.z + + skinning.matrices[jointBase + i32(input.joints.w)] * input.weights.w; +} + +@vertex +fn directional_shadow_3d_vertex(input: ShadowInput) -> ShadowVertex { + var localPosition = input.position; + @if(ADA_MORPH) localPosition = morphPosition(localPosition, input.vertexIndex, false); + let model = mat4x4f(input.model0, input.model1, input.model2, input.model3) * skinTransform(input); + let position = shadowView.viewProjection * model * vec4f(localPosition, 1.0); + return ShadowVertex(position, input.uv0, input.uv1, input.color.a * input.vertexColor.a, + shadowView.tileBounds, input.flags.w, meshOrientation(model)); +} + +@fragment +fn directional_shadow_3d_fragment(input: ShadowVertex, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4f { + let pixel = input.position.xy; + if any(pixel < input.tileBounds.xy) || any(pixel >= input.tileBounds.zw) + || !isVisibleFade(pixel, input.fade) || !isVisibleFace(frontFacing, input.orientation) { + discard; + } + let mode = surface.surfaceProperties.w; + if mode > 0.5 && mode < 1.5 { + var alpha = 0.0; + @if(ADA_SHADER_GRAPH) { + let graph = ada_graph_evaluate(input.uv0); + alpha = graph.color.a * graph.alpha * input.alpha; + } + @if(!ADA_SHADER_GRAPH) { + alpha = baseColorAlpha(input.uv0, input.uv1) * input.alpha; + } + if alpha < surface.surfaceProperties.z { + discard; + } + } + return vec4f(input.position.z); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/dynamic_irradiance_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/dynamic_irradiance_3d.wesl new file mode 100644 index 000000000..2cd1997d2 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/dynamic_irradiance_3d.wesl @@ -0,0 +1,143 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct IrradianceUniform { + grid: vec4f, + update: vec4f, + blend: vec4f, +} +struct LocalLight { + positionRange: vec4f, + radianceIntensity: vec4f, + directionOuter: vec4f, + innerShadow: vec4f, +} +struct LightingUniform { + counts: vec4f, + additionalDirections: array, + additionalRadiance: array, + lights: array, +} +struct ShadowUniform { + matrices: array, + atlas: vec4f, +} + +@group(0) @binding(0) var positions: texture_2d; +@group(0) @binding(1) var normals: texture_2d; +@group(0) @binding(2) var albedoImage: texture_2d; +@group(0) @binding(3) var previousImage: texture_2d; +@group(0) @binding(4) var irradiance: IrradianceUniform; +@group(0) @binding(6) var staticSH: texture_2d; +@group(0) @binding(25) var lighting: LightingUniform; +@group(0) @binding(26) var shadowImage: texture_2d; +@group(0) @binding(28) var shadows: ShadowUniform; + +@vertex +fn dynamic_probe_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn pointFace(ray: vec3f) -> i32 { + let axis = abs(ray); + if axis.x >= axis.y && axis.x >= axis.z { + return select(1, 0, ray.x >= 0.0); + } + if axis.y >= axis.z { + return select(3, 2, ray.y >= 0.0); + } + return select(5, 4, ray.z >= 0.0); +} + +fn localVisibility(light: LocalLight, world: vec3f) -> f32 { + let slot = i32(light.innerShadow.w); + if slot < 0 || slot >= 4 { + return 1.0; + } + let face = select(0, pointFace(world - light.positionRange.xyz), light.directionOuter.w < 0.0); + let clip = shadows.matrices[slot * 6 + face] * vec4f(world, 1.0); + if clip.w <= 0.0 { + return 1.0; + } + let ndc = clip.xyz / clip.w; + if ndc.z < 0.0 || ndc.z > 1.0 || any(abs(ndc.xy) > vec2f(1.0)) { + return 1.0; + } + let atlas = shadows.atlas; + let origin = vec2f(f32(face), f32(slot)) * atlas.z; + let uv = (origin + (ndc.xy * vec2f(0.5, -0.5) + 0.5) * atlas.z) * atlas.xy; + let bounded = clamp(uv, (origin + 0.5) * atlas.xy, (origin + atlas.z - 0.5) * atlas.xy); + let size = vec2i(textureDimensions(shadowImage, 0)); + let pixel = clamp(vec2i(bounded * vec2f(size)), vec2i(0), size - 1); + let stored = textureLoad(shadowImage, pixel, 0).r; + return select(0.0, 1.0, ndc.z - light.innerShadow.y <= stored); +} + +fn incoming(world: vec3f, normal: vec3f) -> vec3f { + var result = vec3f(0.0); + for (var index = 0; index < min(4, i32(irradiance.update.w)); index++) { + let light = lighting.lights[index]; + let delta = light.positionRange.xyz - world; + let distanceToLight = length(delta); + let range = light.positionRange.w; + if distanceToLight >= range || range <= 0.0 { + continue; + } + let direction = delta / max(distanceToLight, 0.0001); + let cosine = max(dot(normal, direction), 0.0); + if cosine <= 0.0 { + continue; + } + let ratio = distanceToLight / range; + let cutoff = max(1.0 - ratio * ratio * ratio * ratio, 0.0); + var attenuation = cutoff * cutoff / max(distanceToLight * distanceToLight, 0.01); + if light.directionOuter.w >= 0.0 { + attenuation *= smoothstep(light.directionOuter.w, light.innerShadow.x, dot(-direction, light.directionOuter.xyz)); + } + if attenuation > 0.0 { + result += light.radianceIntensity.rgb * (light.radianceIntensity.w * attenuation * cosine + * localVisibility(light, world + normal * 0.012) / 3.14159265359); + } + } + return clamp(result, vec3f(0.0), vec3f(32.0)); +} + +@fragment +fn dynamic_probe_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let pixel = vec2i(input.position.xy); + let grid = irradiance.grid; + let update = irradiance.update; + let count = i32(grid.x * grid.y * grid.z); + let samples = i32(grid.w); + let probe = pixel.y * i32(grid.x) + pixel.x / 9; + let coefficient = pixel.x % 9; + if update.w < 0.5 || textureLoad(staticSH, vec2i(pixel.x / 9 * 9, pixel.y), 0).a < 0.5 { + return vec4f(0.0); + } + let previous = select(textureLoad(previousImage, pixel, 0), vec4f(0.0), update.z > 0.5); + let age = (probe - i32(update.x) + count) % count; + if age >= i32(update.y) { + return previous; + } + var value = vec3f(0.0); + for (var index = 0; index < min(256, samples); index++) { + let address = vec2i(pixel.x / 9 * samples + index, pixel.y); + let hit = textureLoad(positions, address, 0); + if hit.a < 0.5 { + continue; + } + let normal = normalize(textureLoad(normals, address, 0).xyz); + let albedo = clamp(textureLoad(albedoImage, address, 0).rgb, vec3f(0.0), vec3f(0.96)); + let y = 1.0 - 2.0 * (f32(index) + 0.5) / f32(samples); + let radius = sqrt(max(1.0 - y * y, 0.0)); + let phi = f32(index) * 2.399963229728653; + let d = vec3f(radius * cos(phi), y, radius * sin(phi)); + let basis = array( + 0.2820947918, 0.4886025119*d.y, 0.4886025119*d.z, 0.4886025119*d.x, + 1.0925484306*d.x*d.y, 1.0925484306*d.y*d.z, 0.3153915653*(3.0*d.z*d.z-1.0), + 1.0925484306*d.x*d.z, 0.5462742153*(d.x*d.x-d.y*d.y), + ); + let convolution = select(select(0.25, 0.6666666667, coefficient < 4), 1.0, coefficient == 0); + value += albedo * incoming(hit.xyz, normal) * (basis[coefficient] * convolution * 12.56637061436 / f32(samples)); + } + return vec4f(mix(value * irradiance.blend.y, previous.rgb, select(irradiance.blend.x, 0.0, update.z > 0.5)), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/environment_composite.wesl b/Sources/AdaCorePipelines/Assets/Shaders/environment_composite.wesl new file mode 100644 index 000000000..5b2dbb302 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/environment_composite.wesl @@ -0,0 +1,121 @@ +import package::tonemapping::{outputTransform, ditherDisplay}; +import package::color_grading::{applyLut, gradeLinear}; + +struct EnvironmentUniform { + projection: mat4x4f, + inverseProjection: mat4x4f, + inverseView: mat4x4f, + zenithColor: vec4f, + horizonColor: vec4f, + groundColor: vec4f, + clearColor: vec4f, + reflection: vec4f, + reflectionQuality: vec4f, + environmentFlags: vec4f, + starfield: vec4f, + ibl: vec4f, + quality: vec4f, + postProcess: vec4f, + grading: vec4f, + vignette: vec4f, + fogColor: vec4f, + fogShape: vec4f, + fogRange: vec4f, + fogSun: vec4f, + fogSunColor: vec4f, + probePosition: vec4f, + probeMinimum: vec4f, + probeMaximum: vec4f, + diffuseGI: vec4f, +} + +@group(0) @binding(0) var sceneColor: texture_2d; +@group(0) @binding(1) var normalRoughness: texture_2d; +@group(0) @binding(2) var viewPositionMetallic: texture_2d; +@group(0) @binding(3) var linearSampler: sampler; +@group(0) @binding(4) var environment: EnvironmentUniform; +@group(0) @binding(11) var gBufferSampler: sampler; +@group(0) @binding(12) var bloomImage: texture_2d; +@group(0) @binding(13) var gradingLut: texture_2d; +@group(0) @binding(14) var gradingSampler: sampler; +@group(0) @binding(15) var exposureImage: texture_2d; +@group(0) @binding(16) var volumetricImage: texture_2d; + +fn srgbToLinear(value: vec3f) -> vec3f { + return mix(value / 12.92, pow((value + 0.055) / 1.055, vec3f(2.4)), step(vec3f(0.04045), value)); +} + +fn presentColor(linearColor: vec3f, uv: vec2f, pixel: vec2f) -> vec3f { + var exposureState = vec2f(0.0); + if environment.vignette.y > 0.5 { + exposureState = textureSampleLevel(exposureImage, gradingSampler, vec2f(0.5), 0.0).rg; + } + let automatic = exp2(exposureState.x + exposureState.y); + let exposed = linearColor * (environment.postProcess.x * automatic) + + textureSampleLevel(bloomImage, linearSampler, uv, 0.0).rgb * environment.postProcess.y; + let graded = gradeLinear(exposed, environment.grading, environment.vignette, uv); + if environment.quality.z > 0.5 { + return graded; + } + let display = applyLut(gradingLut, gradingSampler, outputTransform(graded, environment.postProcess.z), environment.postProcess.w); + return ditherDisplay(display, pixel); +} + +fn volumetricDepthAt(uv: vec2f) -> f32 { + let position = textureSampleLevel(viewPositionMetallic, gBufferSampler, uv, 0.0).xyz; + return select(10000.0, position.z, length(position) > 0.0001); +} + +// Weight neighboring volumetric texels by depth to avoid scattering across silhouettes. +fn volumetricScatter(uv: vec2f, depth: f32) -> vec4f { + if environment.fogRange.z < 0.5 { + return vec4f(0.0); + } + let size = vec2f(textureDimensions(volumetricImage, 0)); + let base = floor(uv * size - 0.5); + let fraction = fract(uv * size - 0.5); + var sum = vec4f(0.0); + var plain = vec4f(0.0); + var weights = 0.0; + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + let sampleUV = clamp((base + vec2f(f32(x), f32(y)) + 0.5) / size, 0.5 / size, vec2f(1.0) - 0.5 / size); + let bilinear = select(fraction.x, 1.0 - fraction.x, x == 0) * select(fraction.y, 1.0 - fraction.y, y == 0); + let value = textureSampleLevel(volumetricImage, gBufferSampler, sampleUV, 0.0); + let weight = bilinear * exp(-abs(volumetricDepthAt(sampleUV) - depth) / max(0.1 * depth, 0.05)); + sum += value * weight; + plain += value * bilinear; + weights += weight; + } + } + if weights > 0.001 { + return sum / weights; + } + return plain; +} + +fn applyFog(linearColor: vec3f, viewPosition: vec3f, skyAmount: f32, isSky: bool, uv: vec2f) -> vec3f { + if environment.fogColor.w <= 0.0 && environment.fogRange.z < 0.5 { + return linearColor; + } + let travel = max(length(viewPosition) - environment.fogShape.z, 0.0); + let worldDirection = normalize((environment.inverseView * vec4f(viewPosition, 0.0)).xyz); + let falloff = environment.fogShape.x; + let cameraHeight = environment.inverseView[3].y; + let rise = falloff * worldDirection.y; + let exponent = clamp(rise * travel, -60.0, 60.0); + var pathFactor = travel; + if abs(rise * travel) > 0.0001 { + pathFactor = (1.0 - exp(-exponent)) / rise; + } + let baseDensity = environment.fogColor.w * exp(clamp(-falloff * (cameraHeight - environment.fogShape.y), -60.0, 60.0)); + let opacity = min(1.0 - exp(-baseDensity * pathFactor), environment.fogShape.w) * skyAmount; + let sunAlignment = pow(max(dot(worldDirection, environment.fogSun.xyz), 0.0), environment.fogRange.y); + let scattered = srgbToLinear(environment.fogColor.rgb) + environment.fogSunColor.rgb * (environment.fogSun.w * sunAlignment); + let volume = volumetricScatter(uv, select(viewPosition.z, 10000.0, isSky)); + let color = mix(linearColor, scattered, opacity); + if environment.fogRange.w < 0.5 { + return color + volume.rgb; + } + return color * (1.0 - volume.a * skyAmount) + volume.rgb * skyAmount; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/flat3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.wesl new file mode 100644 index 000000000..0b02db281 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/flat3d.wesl @@ -0,0 +1,1576 @@ +import ada_engine::view::view; +import ada_engine::tonemapping::adaLinearToSrgb; +import ada_engine::shadow_pcf::{adaDirectionalShadowPCF, adaDirectionalShadowPCSS, adaNearestShadowDepth}; +import ada_engine::baked_diffuse::adaBakedDiffuse; +import ada_engine::distance_field::{adaDistanceFieldVisibility, distanceField}; +@if(ADA_MORPH) import ada_engine::morph::applyMorph; + +// WESL port of flat3d.glsl. Retained resources keep their original binding slots. +// +// Differences from the GLSL that the port requires: +// * Derivatives are taken once, at the top of the fragment stage, in uniform control flow. Texture reads inside +// data-dependent branches use explicit gradients (transformed by the texture's UV transform) instead of implicit ones. +// * `inverse`, `mod`, `out` parameters and mutable globals are expressed with helper functions, structs and `var`. +// * Shader Graph materials (ADA_SHADER_GRAPH) receive the generated graph source at the marker below. + +// ADA_GRAPH_SOURCE_MARKER + +struct DirectionalLight3DUniform { + u_LightDirectionIntensity: vec4f, + u_LightRadianceAmbient: vec4f, + u_ShadowViewProjection: mat4x4f, + u_ShadowParameters: vec4f, + u_ShadowViewProjection1: mat4x4f, + u_ShadowViewProjection2: mat4x4f, + u_CascadeSplits: vec4f, + u_ShadowAtlas: vec4f, +} + +struct Skinning3DUniform { + u_JointMatrices: array, +} + +struct PBR3DUniform { + u_EmissiveFactor: vec4f, + u_SurfaceProperties: vec4f, + u_SurfaceFlags: vec4f, + u_UVSets: vec4f, + u_Emission: vec4f, + u_Parallax: vec4f, + // Per texture slot (base color, metallic/roughness, normal, occlusion, emission): xyzw is a row-major 2x2 + // matrix, then xy is the offset (glTF KHR_texture_transform). + u_TextureTransforms: array, + u_Dielectric: vec4f, + u_Coat: vec4f, + u_Sheen: vec4f, + u_Anisotropy: vec4f, + u_LayerSets: array, + u_LayerTransforms: array, + u_LayerParameters: vec4f, + u_LightmapScaleOffset: vec4f, + u_BakedParameters: vec4f, +} + +struct IBL3DUniform { + u_EnvironmentInverseView: mat4x4f, + u_IBLParameters: vec4f, + u_ProbePosition: vec4f, + u_ProbeMinimum: vec4f, + u_ProbeMaximum: vec4f, +} + +struct LocalLight3D { + positionRange: vec4f, + radianceIntensity: vec4f, + directionOuter: vec4f, + innerShadow: vec4f, +} + +struct LocalLighting3DUniform { + u_LightCounts: vec4f, + u_AdditionalDirections: array, + u_AdditionalRadiance: array, + u_LocalLights: array, +} + +struct LocalShadow3DUniform { + u_LocalShadowMatrices: array, + u_LocalAtlas: vec4f, +} + +struct LightTiles3DUniform { + u_TileProjection: mat4x4f, + u_TileViewport: vec4f, // xy pixel origin, zw pixel size of the viewport the projection maps to + u_TileInfo: vec4f, // x tile size in pixels, y columns, z rows, w 1 when tile masks are valid +} + +struct LightTileMasks3DUniform { + u_TileMasks: array, +} + +struct SurfaceLighting3DUniform { + u_ContactProjection: mat4x4f, + u_ContactInverseProjection: mat4x4f, + u_Contact: vec4f, // enabled, ray length, thickness, bias (view-space meters) + u_ContactQuality: vec4f, // steps, max distance, primary light casts shadows + u_ShadowFilter: vec4f, // PCSS, tan angular radius, max filter radius, blocker search radius +} + +@group(0) @binding(1) var directional: DirectionalLight3DUniform; +@group(0) @binding(14) var skinning: Skinning3DUniform; +@group(0) @binding(15) var pbr: PBR3DUniform; +@group(0) @binding(22) var ibl: IBL3DUniform; +@group(0) @binding(25) var localLighting: LocalLighting3DUniform; +@group(0) @binding(28) var localShadow: LocalShadow3DUniform; +@group(0) @binding(27) var lightTiles: LightTiles3DUniform; +@group(0) @binding(29) var lightTileMasks: LightTileMasks3DUniform; +@group(0) @binding(23) var surfaceLighting: SurfaceLighting3DUniform; + +@if(ADA_TERRAIN) @group(0) @binding(4) var u_TerrainAlbedo: texture_2d_array; +@if(!ADA_TERRAIN) @group(0) @binding(4) var u_BaseColorTexture: texture_2d; +@group(0) @binding(5) var u_MetallicRoughnessTexture: texture_2d; +@group(0) @binding(6) var u_NormalTexture: texture_2d; +@group(0) @binding(7) var u_BaseColorSampler: sampler; +@group(0) @binding(8) var u_MetallicRoughnessSampler: sampler; +@group(0) @binding(9) var u_NormalSampler: sampler; +@group(0) @binding(10) var u_DirectionalShadowTexture: texture_2d; +@if(ADA_TERRAIN) @group(0) @binding(12) var u_TerrainNormal: texture_2d_array; +@if(!ADA_TERRAIN) @group(0) @binding(12) var u_EmissiveTexture: texture_2d; +@group(0) @binding(13) var u_EmissiveSampler: sampler; +@if(ADA_TERRAIN) @group(0) @binding(16) var u_TerrainORM: texture_2d_array; +@if(!ADA_TERRAIN) @group(0) @binding(16) var u_OcclusionTexture: texture_2d; +@group(0) @binding(17) var u_HeightTexture: texture_2d; +@group(0) @binding(0) var u_OcclusionSampler: sampler; +@group(0) @binding(18) var u_Irradiance: texture_2d; +@group(0) @binding(19) var u_PrefilteredEnvironment: texture_2d; +@group(0) @binding(20) var u_BRDF: texture_2d; +@group(0) @binding(3) var u_IBLSampler: sampler; +@group(0) @binding(26) var u_LocalShadowTexture: texture_2d; +@group(0) @binding(41) var u_ContactSceneDepth: texture_2d; + +// Layer textures exist only in the shader variants of materials that have them. They are read with the base color +// sampler, because the main pass has no free sampler slot. +@if(ADA_CLEARCOAT_TEXTURE) @group(0) @binding(32) var u_ClearcoatTexture: texture_2d; +@if(ADA_CLEARCOAT_ROUGHNESS_TEXTURE) @group(0) @binding(33) var u_ClearcoatRoughnessTexture: texture_2d; +@if(ADA_CLEARCOAT_NORMAL_TEXTURE) @group(0) @binding(34) var u_ClearcoatNormalTexture: texture_2d; +@if(ADA_SHEEN_COLOR_TEXTURE) @group(0) @binding(35) var u_SheenColorTexture: texture_2d; +@if(ADA_SHEEN_ROUGHNESS_TEXTURE) @group(0) @binding(36) var u_SheenRoughnessTexture: texture_2d; +@if(ADA_ANISOTROPY_TEXTURE) @group(0) @binding(37) var u_AnisotropyTexture: texture_2d; +@if(ADA_SPECULAR_TEXTURE) @group(0) @binding(38) var u_SpecularTexture: texture_2d; +@if(ADA_SPECULAR_COLOR_TEXTURE) @group(0) @binding(39) var u_SpecularColorTexture: texture_2d; + +struct Flat3DVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) viewPosition: vec3f, + @location(2) worldPosition: vec3f, + @location(3) viewNormal: vec3f, + @location(4) viewTangent: vec4f, + @location(5) textureCoordinate: vec2f, + @location(6) textureCoordinate1: vec2f, + @location(7) textureFlags: vec4f, + @location(8) shadowFlags: vec4f, + // Roughness, metallic, emissive strength, and emissive light threshold share one interpolator. + @location(9) material: vec4f, + @location(10) @interpolate(flat) modelOrientation: f32, +} + +// --------------------------------------------------------------------------------------------------------------------- +// Vertex stage +// --------------------------------------------------------------------------------------------------------------------- + +fn skinTransform(jointIndices: vec4f, jointWeights: vec4f, shadowFlags: vec4f) -> mat4x4f { + let jointBase = i32(max(0.0, -shadowFlags.y)); + if jointIndices.x < 0.0 { + return mat4x4f(vec4f(1.0, 0.0, 0.0, 0.0), vec4f(0.0, 1.0, 0.0, 0.0), vec4f(0.0, 0.0, 1.0, 0.0), vec4f(0.0, 0.0, 0.0, 1.0)); + } + return skinning.u_JointMatrices[jointBase + i32(jointIndices.x)] * jointWeights.x + + skinning.u_JointMatrices[jointBase + i32(jointIndices.y)] * jointWeights.y + + skinning.u_JointMatrices[jointBase + i32(jointIndices.z)] * jointWeights.z + + skinning.u_JointMatrices[jointBase + i32(jointIndices.w)] * jointWeights.w; +} + +fn upperLeft(m: mat4x4f) -> mat3x3f { + return mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz); +} + +// WGSL has no inverse(). For a 3x3 matrix with columns c0, c1, c2 the inverse transpose has columns +// (c1 x c2, c2 x c0, c0 x c1) / det. +fn inverseTranspose3(m: mat3x3f) -> mat3x3f { + let determinantValue = determinant(m); + return mat3x3f(cross(m[1], m[2]), cross(m[2], m[0]), cross(m[0], m[1])) * (1.0 / determinantValue); +} + +fn inverse3(m: mat3x3f) -> mat3x3f { + return transpose(inverseTranspose3(m)); +} + +@vertex +fn flat3d_vertex( + @builtin(vertex_index) vertexIndex: u32, + @location(0) aPosition: vec3f, + @location(1) aNormal: vec3f, + @location(2) aTextureCoordinate: vec2f, + @location(3) aVertexColor: vec4f, + @location(4) aTangent: vec4f, + @location(5) aModel0: vec4f, + @location(6) aModel1: vec4f, + @location(7) aModel2: vec4f, + @location(8) aModel3: vec4f, + @location(9) aColor: vec4f, + @location(10) aMaterial: vec4f, + @location(11) aTextureFlags: vec4f, + @location(12) aShadowFlags: vec4f, + @location(13) aJointIndices: vec4f, + @location(14) aJointWeights: vec4f, + @location(15) aTextureCoordinate1: vec2f +) -> Flat3DVertex { + // Morph targets move the rest pose first; skinning then moves the morphed pose. + var localPosition = aPosition; + var localNormal = aNormal; + var localTangent = aTangent.xyz; + @if(ADA_MORPH) { + let morphed = applyMorph(vertexIndex, localPosition, localNormal, localTangent); + localPosition = morphed.position; + localNormal = morphed.normal; + localTangent = morphed.tangent; + } + let model = mat4x4f(aModel0, aModel1, aModel2, aModel3) * skinTransform(aJointIndices, aJointWeights, aShadowFlags); + let basis = upperLeft(model); + let basisDeterminant = determinant(basis); + let orientation = select(1.0, -1.0, basisDeterminant < 0.0); + var normalMatrix = mat3x3f(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0)); + if abs(basisDeterminant) > 0.000001 { + normalMatrix = inverseTranspose3(basis); + } + let normal = normalize(normalMatrix * localNormal); + let worldPosition = model * vec4f(localPosition, 1.0); + let worldTangent = normalize(basis * localTangent); + let viewBasis = upperLeft(view.viewMatrix); + + var out: Flat3DVertex; + out.color = aColor * aVertexColor; + out.worldPosition = worldPosition.xyz; + out.viewPosition = (view.viewMatrix * worldPosition).xyz; + out.viewNormal = normalize(viewBasis * normal); + @if(ADA_TERRAIN) { + // Reuse the terrain-unused tangent slot to stay below WebGPU's inter-stage variable limit. + // X/Z already travel in TextureCoordinate; this packs the local normal and local height. + out.viewTangent = vec4f(localNormal, localPosition.y); + } + @if(!ADA_TERRAIN) { + out.viewTangent = vec4f(normalize(viewBasis * worldTangent), aTangent.w * orientation); + } + out.textureCoordinate = aTextureCoordinate; + out.textureCoordinate1 = aTextureCoordinate1; + out.textureFlags = aTextureFlags; + out.shadowFlags = vec4f(aShadowFlags.x, max(0.0, aShadowFlags.y), aShadowFlags.z, aShadowFlags.w); + out.material = vec4f(clamp(aMaterial.x, 0.04, 1.0), clamp(aMaterial.y, 0.0, 1.0), max(aMaterial.z, 0.0), aMaterial.w); + out.modelOrientation = orientation; + out.position = view.viewProjection * worldPosition; + @if(ADA_WIREFRAME) { + // WebGPU disallows pipeline depth bias on lines. Offset along view depth by a bounded scene distance, + // then keep the original screen position. A fixed NDC offset can pull distant hidden edges through their occluder. + let depthOffset = min(0.002, abs(out.viewPosition.z) * 0.01); + let nearer = out.position.zw - sign(out.viewPosition.z) * depthOffset * view.projection[2].zw; + if nearer.y > 0.0 && nearer.x >= 0.0 { + out.position.z = min(out.position.z, out.position.w * (nearer.x / nearer.y)); + } + } + return out; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Fragment stage +// --------------------------------------------------------------------------------------------------------------------- + +struct Flat3DOutput { + @location(0) color: vec4f, + @location(1) normalRoughness: vec4f, + @location(2) viewPositionMetallic: vec4f, + @location(3) indirectLighting: vec4f, + @if(ADA_HYBRID_REFLECTION) @location(4) reflectionResponse: vec4f, + @if(ADA_DIFFUSE_SURFACE && ADA_HYBRID_REFLECTION) @location(5) diffuseSurface: vec4f, + @if(ADA_DIFFUSE_SURFACE && !ADA_HYBRID_REFLECTION) @location(4) diffuseSurface: vec4f, + @builtin(frag_depth) depth: f32, +} + +const PI: f32 = 3.14159265359; +const SHEEN_ALBEDO: f32 = 0.4; + +// Per-fragment state. The GLSL kept these as globals; WGSL private variables are initialized per invocation. +var Input: Flat3DVertex; +var g_FrontFacing: bool; +var g_PositionDx: vec3f; +var g_PositionDy: vec3f; +var g_UVDx: vec2f; +var g_UVDy: vec2f; +var g_UV1Dx: vec2f; +var g_UV1Dy: vec2f; +// Clearcoat layer: x strength, y roughness. It sits on the geometric normal, not on the normal map. +var g_Clearcoat: vec2f = vec2f(0.0); +var g_CoatNormal: vec3f = vec3f(0.0, 0.0, 1.0); +// Reflectance of non-metals at normal incidence for this fragment: the material value times its specular textures. +var g_Dielectric: vec3f = vec3f(0.04); +var g_ReflectionResponse: vec3f = vec3f(0.0); +// Sheen layer (cloth): rgb color, w roughness. A black color turns it off. +var g_Sheen: vec4f = vec4f(0.0); +// Anisotropy: strength, and the view-space tangent and bitangent of the stretched highlight. +var g_Anisotropy: f32 = 0.0; +var g_AnisotropyT: vec3f = vec3f(1.0, 0.0, 0.0); +var g_AnisotropyB: vec3f = vec3f(0.0, 1.0, 0.0); +// UV shift from parallax occlusion mapping, applied to every material texture that uses the height map's UV set. +var g_ParallaxDelta: vec2f = vec2f(0.0); + +fn surfaceFrontFacing() -> bool { + // Reflection reverses raster winding, while inverse-transpose normals still face physically outward. + return select(g_FrontFacing, !g_FrontFacing, Input.modelOrientation < 0.0); +} + +fn hasClearcoat() -> bool { + @if(ADA_CLEARCOAT) { + return g_Clearcoat.x > 0.0; + } + return false; +} + +fn hasSheen() -> bool { + @if(ADA_SHEEN) { + return max(g_Sheen.r, max(g_Sheen.g, g_Sheen.b)) > 0.0; + } + return false; +} + +fn hasAnisotropy() -> bool { + @if(ADA_ANISOTROPY) { + return g_Anisotropy > 0.0; + } + return false; +} + +// Share of the base layer's light that survives the coat at this view angle. +fn coatTransmission(normalView: f32) -> f32 { + return 1.0 - g_Clearcoat.x * fresnelSchlick(normalView, vec3f(0.04)).x; +} + +// Share of the base layer's light that survives the sheen. +fn sheenTransmission() -> f32 { + return 1.0 - max(g_Sheen.r, max(g_Sheen.g, g_Sheen.b)) * SHEEN_ALBEDO; +} + +// A texture coordinate with the gradients a sample needs, so reads can sit in data-dependent branches. +struct MaterialUV { + uv: vec2f, + dx: vec2f, + dy: vec2f, +} + +fn transformedUV(setIndex: f32, matrix: vec4f, offset: vec2f) -> MaterialUV { + let useSecond = setIndex > 0.5; + var uv = select(Input.textureCoordinate, Input.textureCoordinate1, useSecond); + let dx = select(g_UVDx, g_UV1Dx, useSecond); + let dy = select(g_UVDy, g_UV1Dy, useSecond); + // The parallax shift lives in mesh UV space, so it applies before the texture's own transform. + if abs(setIndex - pbr.u_Parallax.y) < 0.5 { + uv += g_ParallaxDelta; + } + return MaterialUV( + vec2f(dot(matrix.xy, uv), dot(matrix.zw, uv)) + offset, + vec2f(dot(matrix.xy, dx), dot(matrix.zw, dx)), + vec2f(dot(matrix.xy, dy), dot(matrix.zw, dy)) + ); +} + +fn materialUV(setIndex: f32, slot: i32) -> MaterialUV { + return transformedUV(setIndex, pbr.u_TextureTransforms[slot * 2], pbr.u_TextureTransforms[slot * 2 + 1].xy); +} + +// UV of a layer texture (slot 0 to 7 in PBRLayerTexture order): its UV set, with the parallax shift, then its own transform. +fn layerUV(slot: i32) -> MaterialUV { + let setIndex = pbr.u_LayerSets[slot >> 2u][slot & 3]; + return transformedUV(setIndex, pbr.u_LayerTransforms[slot * 2], pbr.u_LayerTransforms[slot * 2 + 1].xy); +} + +fn sampleMaterial(tex: texture_2d, samp: sampler, coordinate: MaterialUV) -> vec4f { + return textureSampleGrad(tex, samp, coordinate.uv, coordinate.dx, coordinate.dy); +} + +fn contactVisibility(normal: vec3f, direction: vec3f, lightDistance: f32) -> f32 { + let contact = surfaceLighting.u_Contact; + let quality = surfaceLighting.u_ContactQuality; + if contact.x < 0.5 || Input.shadowFlags.x < 0.5 || Input.viewPosition.z >= quality.y { + return 1.0; + } + let steps = i32(quality.x); + let reach = min(contact.y, lightDistance); + let origin = Input.viewPosition + normal * contact.w; + let size = vec2i(textureDimensions(u_ContactSceneDepth, 0)); + for (var i = 1; i <= 32; i++) { + if i > steps { + break; + } + let t = (f32(i) - 0.25) / f32(steps); + let query = origin + direction * (reach * t); + let clip = surfaceLighting.u_ContactProjection * vec4f(query, 1.0); + if clip.w <= 0.0 { + break; + } + let uv = clip.xy / clip.w * vec2f(0.5, -0.5) + vec2f(0.5); + if any(uv <= vec2f(0.0)) || any(uv >= vec2f(1.0)) { + break; + } + let depth = textureLoad(u_ContactSceneDepth, vec2i(uv * vec2f(size)), 0).r; + if depth >= 1.0 { + continue; + } + let surface = surfaceLighting.u_ContactInverseProjection * vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), depth, 1.0); + let gap = query.z - surface.z / surface.w; + if gap > contact.w && gap < contact.z { + let fade = 1.0 - smoothstep(quality.y * 0.8, quality.y, Input.viewPosition.z); + return 1.0 - fade; + } + } + return 1.0; +} + +// Bit mask of the local lights that may reach this fragment. The tile comes from projecting the view-space +// position the same way the CPU bounded each light, so it holds for any backend's fragment coordinate origin. +fn localLightMask() -> u32 { + let lightCount = i32(localLighting.u_LightCounts.y); + let reachable = select((1u << u32(max(lightCount, 0))) - 1u, 0xFFFFFFFFu, lightCount >= 32); + let info = lightTiles.u_TileInfo; + if info.w < 0.5 { + return reachable; + } + let clip = lightTiles.u_TileProjection * vec4f(Input.viewPosition, 1.0); + if clip.w <= 0.0001 { + return reachable; + } + let viewport = lightTiles.u_TileViewport; + let pixel = viewport.xy + vec2f(clip.x / clip.w * 0.5 + 0.5, 0.5 - clip.y / clip.w * 0.5) * viewport.zw; + let tile = clamp(vec2i(floor(pixel / info.x)), vec2i(0), vec2i(i32(info.y) - 1, i32(info.z) - 1)); + let index = tile.y * i32(info.y) + tile.x; + return lightTileMasks.u_TileMasks[index >> 2u][index & 3] & reachable; +} + +fn srgbToLinear(value: vec3f) -> vec3f { + return mix(value / 12.92, pow((value + vec3f(0.055)) / 1.055, vec3f(2.4)), step(vec3f(0.04045), value)); +} + +fn distributionGGX(normal: vec3f, halfway: vec3f, roughness: f32) -> f32 { + let alpha = roughness * roughness; + let alphaSquared = alpha * alpha; + let normalHalfway = max(dot(normal, halfway), 0.0); + let denominator = normalHalfway * normalHalfway * (alphaSquared - 1.0) + 1.0; + return alphaSquared / max(PI * denominator * denominator, 0.000001); +} + +fn geometrySchlickGGX(normalDirection: f32, roughness: f32) -> f32 { + let radius = roughness + 1.0; + let k = radius * radius / 8.0; + return normalDirection / max(normalDirection * (1.0 - k) + k, 0.000001); +} + +fn geometrySmith(normal: vec3f, viewDirection: vec3f, lightDirection: vec3f, roughness: f32) -> f32 { + return geometrySchlickGGX(max(dot(normal, viewDirection), 0.0), roughness) + * geometrySchlickGGX(max(dot(normal, lightDirection), 0.0), roughness); +} + +fn fresnelSchlick(cosine: f32, reflectanceAtNormal: vec3f) -> vec3f { + return reflectanceAtNormal + (vec3f(1.0) - reflectanceAtNormal) * pow(clamp(1.0 - cosine, 0.0, 1.0), 5.0); +} + +// Ray-marches the height map along the view ray. Returns the UV shift that makes the surface appear to have depth. +// Depth is measured in view units and converted to UV through the screen-space position/UV derivatives, so no +// tangent frame is needed. +fn parallaxDelta() -> vec2f { + let parallax = pbr.u_Parallax; + var normal = normalize(Input.viewNormal); + let viewDirection = normalize(-Input.viewPosition); + var facing = dot(normal, viewDirection); + if facing < 0.0 { + normal = -normal; + facing = -facing; + } + let fadeDistance = parallax.w; + let scale = parallax.x * (1.0 - smoothstep(fadeDistance * 0.5, fadeDistance, length(Input.viewPosition))); + let useSecond = parallax.y > 0.5; + let uv = select(Input.textureCoordinate, Input.textureCoordinate1, useSecond); + let gradientX = select(g_UVDx, g_UV1Dx, useSecond); + let gradientY = select(g_UVDy, g_UV1Dy, useSecond); + let positionX = g_PositionDx; + let positionY = g_PositionDy; + let xx = dot(positionX, positionX); + let xy = dot(positionX, positionY); + let yy = dot(positionY, positionY); + let determinantValue = xx * yy - xy * xy; + if scale <= 0.0 || abs(determinantValue) < 1e-12 { + return vec2f(0.0); + } + + let shift = -(viewDirection - normal * facing) / max(facing, 0.08); + let projected = vec2f(dot(shift, positionX), dot(shift, positionY)); + let coefficients = vec2f(yy * projected.x - xy * projected.y, xx * projected.y - xy * projected.x) / determinantValue; + let uvPerDepth = (gradientX * coefficients.x + gradientY * coefficients.y) * scale; + + let maximumLayers = parallax.z; + let layers = mix(maximumLayers, max(maximumLayers * 0.25, 4.0), clamp(facing, 0.0, 1.0)); + var previousDepth = 0.0; + var previousGap = -(1.0 - textureSampleGrad(u_HeightTexture, u_BaseColorSampler, uv, gradientX, gradientY).r); + var hitDepth = 1.0; + for (var i = 1; i <= 64; i++) { + if f32(i) > layers { + break; + } + let depth = f32(i) / layers; + let height = textureSampleGrad(u_HeightTexture, u_BaseColorSampler, uv + uvPerDepth * depth, gradientX, gradientY).r; + let gap = depth - (1.0 - height); + if gap >= 0.0 { + hitDepth = mix(previousDepth, depth, previousGap / (previousGap - gap)); + break; + } + previousDepth = depth; + previousGap = gap; + } + return uvPerDepth * hitDepth; +} + +fn environmentUV(direction: vec3f) -> vec2f { + let angle = ibl.u_IBLParameters.w; + let rotated = mat2x2f(cos(angle), -sin(angle), sin(angle), cos(angle)) * direction.xz; + return vec2f(atan2(rotated.y, rotated.x) / (2.0 * PI) + 0.5, asin(clamp(direction.y, -1.0, 1.0)) / PI + 0.5); +} + +// A capture contains static geometry even when it lies behind the camera. Correct its finite-distance parallax. +fn probeDirection(position: vec3f, direction: vec3f) -> vec3f { + if ibl.u_ProbePosition.w < 0.5 || any(position < ibl.u_ProbeMinimum.xyz) || any(position > ibl.u_ProbeMaximum.xyz) { + return direction; + } + let nearZero = abs(direction) < vec3f(0.000001); + let safeDirection = select(direction, vec3f(0.000001), nearZero); + let boundary = select(ibl.u_ProbeMinimum.xyz, ibl.u_ProbeMaximum.xyz, direction >= vec3f(0.0)); + var travel = (boundary - position) / safeDirection; + travel = select(travel, vec3f(1e20), nearZero); + let projected = position + direction * min(travel.x, min(travel.y, travel.z)) - ibl.u_ProbePosition.xyz; + return select(direction, normalize(projected), dot(projected, projected) > 0.000001); +} + +fn samplePrefiltered(inputDirection: vec3f, roughness: f32) -> vec3f { + let direction = probeDirection(Input.worldPosition, inputDirection); + var uv = environmentUV(direction); + let levels = max(ibl.u_IBLParameters.z, 2.0); + let layer = roughness * (levels - 1.0); + let lower = floor(layer); + let upper = min(lower + 1.0, levels - 1.0); + let texels = f32(textureDimensions(u_PrefilteredEnvironment, 0).y) / levels; + uv.y = clamp(uv.y, 0.5 / texels, 1.0 - 0.5 / texels); + let left = textureSampleLevel(u_PrefilteredEnvironment, u_IBLSampler, vec2f(uv.x, (lower + uv.y) / levels), 0.0).rgb; + let right = textureSampleLevel(u_PrefilteredEnvironment, u_IBLSampler, vec2f(uv.x, (upper + uv.y) / levels), 0.0).rgb; + return mix(left, right, layer - lower); +} + +struct EnvironmentResult { + color: vec3f, + bakedPart: vec3f, +} + +fn environmentLighting( + normal: vec3f, viewDirection: vec3f, baseColor: vec3f, metallic: f32, roughness: f32, baked: vec4f +) -> EnvironmentResult { + var bakedPart = vec3f(0.0); + let inverseView = upperLeft(ibl.u_EnvironmentInverseView); + let worldNormal = normalize(inverseView * normal); + let worldView = normalize(inverseView * viewDirection); + let ndotv = max(dot(normal, viewDirection), 0.0); + let f0 = mix(g_Dielectric, baseColor, metallic); + let fresnel = f0 + (max(vec3f(1.0 - roughness), f0) - f0) * pow(1.0 - ndotv, 5.0); + let diffuseWeight = (vec3f(1.0) - fresnel) * (1.0 - metallic); + let irradiance = textureSampleLevel(u_Irradiance, u_IBLSampler, environmentUV(worldNormal), 0.0).rgb; + var reflectionNormal = worldNormal; + if hasAnisotropy() { + // Bend the reflection toward the stretched direction so the environment smears along it (Filament). + let anisotropicTangent = cross(g_AnisotropyB, viewDirection); + let anisotropicNormal = cross(anisotropicTangent, g_AnisotropyB); + let bend = g_Anisotropy * clamp(5.0 * roughness, 0.0, 1.0); + reflectionNormal = normalize(inverseView * normalize(mix(normal, anisotropicNormal, bend))); + } + let reflected = samplePrefiltered(reflect(-worldView, reflectionNormal), roughness); + let lutSize = vec2f(textureDimensions(u_BRDF, 0)); + let lutUV = clamp(vec2f(ndotv, roughness), vec2f(0.5) / lutSize, vec2f(1.0) - vec2f(0.5) / lutSize); + let lut = textureSampleLevel(u_BRDF, u_IBLSampler, lutUV, 0.0).rg; + // Preserve the exact colored material response, including AO and transmission applied below. Anisotropy + // uses a different ray direction, so its base lobe stays on the correctly integrated probe in this first slice. + g_ReflectionResponse = select(f0 * lut.x + vec3f(lut.y), vec3f(0.0), hasAnisotropy()); + let iblIntensity = ibl.u_IBLParameters.y; + var result = (diffuseWeight * irradiance * baseColor / PI + reflected * (f0 * lut.x + vec3f(lut.y))) * iblIntensity; + var incident = vec3f(0.0); + @if(ADA_LIGHTMAP || ADA_IRRADIANCE_GRID) { + incident = mix(irradiance / PI * iblIntensity, baked.rgb, baked.a); + result = diffuseWeight * incident * baseColor + reflected * (f0 * lut.x + vec3f(lut.y)) * iblIntensity; + bakedPart = diffuseWeight * baseColor * baked.rgb * baked.a; + } + if hasSheen() { + g_ReflectionResponse *= sheenTransmission(); + // Sheen under image-based lighting is approximated as a diffuse lobe with the average sheen albedo. + result = result * sheenTransmission() + g_Sheen.rgb * SHEEN_ALBEDO * irradiance / PI * iblIntensity; + @if(ADA_LIGHTMAP || ADA_IRRADIANCE_GRID) { + result += g_Sheen.rgb * SHEEN_ALBEDO * (incident - irradiance / PI * iblIntensity); + bakedPart = bakedPart * sheenTransmission() + g_Sheen.rgb * SHEEN_ALBEDO * baked.rgb * baked.a; + } + } + if hasClearcoat() { + g_ReflectionResponse *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); + // The coat reflects the environment along the geometric normal and dims the base layer beneath it. + let coatWorldNormal = normalize(inverseView * g_CoatNormal); + let coatNormalView = max(dot(g_CoatNormal, viewDirection), 0.0); + let coatLutUV = clamp(vec2f(coatNormalView, g_Clearcoat.y), vec2f(0.5) / lutSize, vec2f(1.0) - vec2f(0.5) / lutSize); + let coatLut = textureSampleLevel(u_BRDF, u_IBLSampler, coatLutUV, 0.0).rg; + let coatReflected = samplePrefiltered(reflect(-worldView, coatWorldNormal), g_Clearcoat.y); + result = result * coatTransmission(coatNormalView) + + coatReflected * (0.04 * coatLut.x + coatLut.y) * g_Clearcoat.x * iblIntensity; + bakedPart *= coatTransmission(coatNormalView); + } + return EnvironmentResult(result, bakedPart); +} + +fn cotangentFrame(normal: vec3f, uvX: vec2f, uvY: vec2f) -> mat3x3f { + let positionYPerpendicular = cross(g_PositionDy, normal); + let positionXPerpendicular = cross(normal, g_PositionDx); + let tangent = positionYPerpendicular * uvX.x + positionXPerpendicular * uvY.x; + let bitangent = positionYPerpendicular * uvX.y + positionXPerpendicular * uvY.y; + let scale = inverseSqrt(max(max(dot(tangent, tangent), dot(bitangent, bitangent)), 0.000001)); + return mat3x3f(tangent * scale, bitangent * scale, normal); +} + +fn materialNormal() -> vec3f { + let normal = normalize(Input.viewNormal); + if Input.textureFlags.z < 0.5 { + return normal; + } + + let coordinate = materialUV(pbr.u_UVSets.z, 2); + var tangentNormal = sampleMaterial(u_NormalTexture, u_NormalSampler, coordinate).xyz * 2.0 - vec3f(1.0); + tangentNormal = vec3f(tangentNormal.xy * pbr.u_SurfaceProperties.x, tangentNormal.z); + // Authored tangents follow the mesh UVs, so they only hold while the normal map's transform keeps its axes. + let normalMatrix = pbr.u_TextureTransforms[4]; + let keepsTangents = abs(normalMatrix.y) < 0.0001 && abs(normalMatrix.z) < 0.0001 && normalMatrix.x > 0.0 && normalMatrix.w > 0.0; + if Input.textureFlags.w > 0.5 && pbr.u_UVSets.z < 0.5 && keepsTangents { + let tangent = normalize(Input.viewTangent.xyz - normal * dot(normal, Input.viewTangent.xyz)); + let bitangent = normalize(cross(normal, tangent)) * Input.viewTangent.w; + return normalize(mat3x3f(tangent, bitangent, normal) * tangentNormal); + } + + return normalize(cotangentFrame(normal, coordinate.dx, coordinate.dy) * tangentNormal); +} + +fn cascadeVisibility(cascade: i32, normal: vec3f, lightDirection: vec3f) -> f32 { + var shadowMatrix = directional.u_ShadowViewProjection; + if cascade == 1 { + shadowMatrix = directional.u_ShadowViewProjection1; + } else if cascade > 1 { + shadowMatrix = directional.u_ShadowViewProjection2; + } + let position = shadowMatrix * vec4f(Input.worldPosition, 1.0); + let projected = position.xyz / position.w; + let localUV = projected.xy * vec2f(0.5, -0.5) + vec2f(0.5); + if projected.z < 0.0 || projected.z > 1.0 || any(localUV < vec2f(0.0)) || any(localUV > vec2f(1.0)) { + return 1.0; + } + let atlas = directional.u_ShadowAtlas; + let count = max(directional.u_CascadeSplits.w, 1.0); + let uv = vec2f((localUV.x + f32(cascade)) / count, localUV.y); + let minimum = vec2f(f32(cascade) / count, 0.0) + atlas.xy * 0.5; + let maximum = vec2f(f32(cascade + 1) / count, 1.0) - atlas.xy * 0.5; + let bias = directional.u_ShadowParameters.y + directional.u_ShadowParameters.z * (1.0 - max(dot(normal, lightDirection), 0.0)); + if surfaceLighting.u_ShadowFilter.x > 0.5 { + return adaDirectionalShadowPCSS( + u_DirectionalShadowTexture, + uv, atlas.xy, minimum, maximum, projected.z - bias, shadowMatrix, surfaceLighting.u_ShadowFilter, count + ); + } + return adaDirectionalShadowPCF( + u_DirectionalShadowTexture, uv, atlas.xy, minimum, maximum, projected.z - bias + ); +} + +fn directionalShadow(normal: vec3f, lightDirection: vec3f) -> f32 { + let splits = directional.u_CascadeSplits; + if Input.shadowFlags.x < 0.5 || directional.u_ShadowParameters.x < 0.5 { + return 1.0; + } + let depth = Input.viewPosition.z; + if depth > splits.z { + return 1.0; + } + let count = i32(splits.w); + var cascade = select(select(2, 1, depth <= splits.y), 0, depth <= splits.x); + cascade = min(cascade, count - 1); + var visibility = cascadeVisibility(cascade, normal, lightDirection); + if cascade < count - 1 { + let edge = select(splits.y, splits.x, cascade == 0); + let start = select(splits.x, 0.0, cascade == 0); + let width = (edge - start) * directional.u_ShadowAtlas.z; + if width > 0.0 && depth > edge - width { + let next = cascadeVisibility(cascade + 1, normal, lightDirection); + visibility = mix(visibility, next, smoothstep(edge - width, edge, depth)); + } + } + return visibility; +} + +// The coat's own highlight for one light (rgb, already scaled by radiance and n.l) and the transmission of the +// base layer under it (a). +fn clearcoatDirect(viewDirection: vec3f, lightDirection: vec3f, radiance: vec3f) -> vec4f { + let normal = g_CoatNormal; + let nl = max(dot(normal, lightDirection), 0.0); + let nv = max(dot(normal, viewDirection), 0.0); + let transmission = coatTransmission(nv); + if nl <= 0.0 || nv <= 0.0 { + return vec4f(0.0, 0.0, 0.0, transmission); + } + let halfway = normalize(viewDirection + lightDirection); + let fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), vec3f(0.04)).x * g_Clearcoat.x; + let distribution = distributionGGX(normal, halfway, g_Clearcoat.y); + let geometry = geometrySmith(normal, viewDirection, lightDirection, g_Clearcoat.y); + let specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); + return vec4f(vec3f(specular) * max(radiance, vec3f(0.0)) * nl, transmission); +} + +// Charlie sheen distribution (Estevez and Kulla) and the Neubelt visibility term, as in the glTF sheen specification. +fn sheenDistribution(roughness: f32, normalHalfway: f32) -> f32 { + let alpha = roughness * roughness; + let inverseAlpha = 1.0 / alpha; + let sine2 = max(1.0 - normalHalfway * normalHalfway, 0.0078125); + return (2.0 + inverseAlpha) * pow(sine2, inverseAlpha * 0.5) / (2.0 * PI); +} + +fn sheenVisibility(normalLight: f32, normalView: f32) -> f32 { + return clamp(1.0 / (4.0 * (normalLight + normalView - normalLight * normalView)), 0.0, 1.0); +} + +fn evaluateDirect( + normal: vec3f, viewDirection: vec3f, lightDirection: vec3f, baseColor: vec3f, metallic: f32, roughness: f32, radiance: vec3f +) -> vec3f { + let nl = max(dot(normal, lightDirection), 0.0); + let nv = max(dot(normal, viewDirection), 0.0); + var base = vec3f(0.0); + if nl > 0.0 && nv > 0.0 { + let halfway = normalize(viewDirection + lightDirection); + let f0 = mix(g_Dielectric, baseColor, metallic); + let fresnel = fresnelSchlick(max(dot(halfway, viewDirection), 0.0), f0); + var specular: vec3f; + if hasAnisotropy() { + // Anisotropic GGX: roughness stretches along the tangent, so the highlight streaks along the bitangent. + let normalHalfway = max(dot(normal, halfway), 0.0); + let alpha = roughness * roughness; + let alphaT = mix(alpha, 1.0, g_Anisotropy * g_Anisotropy); + let alphaB = alpha; + let tangentHalfway = dot(g_AnisotropyT, halfway); + let bitangentHalfway = dot(g_AnisotropyB, halfway); + let denominator = tangentHalfway * tangentHalfway / (alphaT * alphaT) + + bitangentHalfway * bitangentHalfway / (alphaB * alphaB) + normalHalfway * normalHalfway; + let distribution = 1.0 / max(PI * alphaT * alphaB * denominator * denominator, 0.000001); + let lambdaView = nl * length(vec3f(alphaT * dot(g_AnisotropyT, viewDirection), alphaB * dot(g_AnisotropyB, viewDirection), nv)); + let lambdaLight = nv * length(vec3f(alphaT * dot(g_AnisotropyT, lightDirection), alphaB * dot(g_AnisotropyB, lightDirection), nl)); + specular = fresnel * distribution * 0.5 / max(lambdaView + lambdaLight, 0.00001); + } else { + let distribution = distributionGGX(normal, halfway, roughness); + let geometry = geometrySmith(normal, viewDirection, lightDirection, roughness); + specular = distribution * geometry * fresnel / max(4.0 * nv * nl, 0.0001); + } + base = ((vec3f(1.0) - fresnel) * (1.0 - metallic) * baseColor / PI + specular) * max(radiance, vec3f(0.0)) * nl; + if hasSheen() { + let sheen = g_Sheen.rgb * sheenDistribution(g_Sheen.w, max(dot(normal, halfway), 0.0)) * sheenVisibility(nl, nv); + base = base * sheenTransmission() + sheen * max(radiance, vec3f(0.0)) * nl; + } + } + if !hasClearcoat() { + return base; + } + let coat = clearcoatDirect(viewDirection, lightDirection, radiance); + return base * coat.a + coat.rgb; +} + +fn localPointFace(ray: vec3f) -> i32 { + let a = abs(ray); + if a.x >= a.y && a.x >= a.z { + return select(1, 0, ray.x >= 0.0); + } + if a.y >= a.z { + return select(3, 2, ray.y >= 0.0); + } + return select(5, 4, ray.z >= 0.0); +} + +fn localShadowVisibility(light: LocalLight3D, lightDirection: vec3f, normal: vec3f) -> f32 { + let slot = i32(light.innerShadow.w); + if slot < 0 || slot >= 4 || Input.shadowFlags.x < 0.5 { + return 1.0; + } + // View-space direction to the surface converted back to world-space for cube-face choice. + let worldRay = upperLeft(ibl.u_EnvironmentInverseView) * (Input.viewPosition - light.positionRange.xyz); + var face = 0; + if light.directionOuter.w < 0.0 { + face = localPointFace(worldRay); + } + let clip = localShadow.u_LocalShadowMatrices[slot * 6 + face] * vec4f(Input.worldPosition, 1.0); + if clip.w <= 0.0 { + return 1.0; + } + let ndc = clip.xyz / clip.w; + if ndc.z < 0.0 || ndc.z > 1.0 || any(abs(ndc.xy) > vec2f(1.0)) { + return 1.0; + } + let atlas = localShadow.u_LocalAtlas; + let tileOrigin = vec2f(f32(face), f32(slot)) * atlas.z; + let texel = atlas.xy; + let uv = (tileOrigin + (ndc.xy * vec2f(0.5, -0.5) + vec2f(0.5)) * atlas.z) * texel; + let lo = (tileOrigin + vec2f(0.5)) * texel; + let hi = (tileOrigin + vec2f(atlas.z - 0.5)) * texel; + let bias = light.innerShadow.y + light.innerShadow.z * (1.0 - max(dot(normal, lightDirection), 0.0)); + let shadowSize = vec2i(textureDimensions(u_LocalShadowTexture)); + var visibility = 0.0; + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + let stored = adaNearestShadowDepth(u_LocalShadowTexture, shadowSize, clamp(uv + vec2f(f32(x), f32(y)) * texel, lo, hi)); + visibility += select(0.0, 1.0, ndc.z - bias <= stored); + } + } + return visibility / 9.0; +} + +fn additionalLighting(normal: vec3f, viewDirection: vec3f, baseColor: vec3f, metallic: f32, roughness: f32) -> vec3f { + var result = vec3f(0.0); + for (var i = 0; i < 3; i++) { + if i >= i32(localLighting.u_LightCounts.x) { + break; + } + result += evaluateDirect( + normal, viewDirection, normalize(localLighting.u_AdditionalDirections[i].xyz), baseColor, metallic, roughness, + localLighting.u_AdditionalRadiance[i].rgb * max(localLighting.u_AdditionalDirections[i].w, 0.0) + ); + } + var mask = localLightMask(); + for (var n = 0; n < 32; n++) { + if mask == 0u { + break; + } + let i = i32(firstTrailingBit(mask)); + mask &= mask - 1u; + let light = localLighting.u_LocalLights[i]; + let delta = light.positionRange.xyz - Input.viewPosition; + let lightDistance = length(delta); + let range = light.positionRange.w; + if lightDistance >= range || range <= 0.0 { + continue; + } + let direction = delta / max(lightDistance, 0.00001); + let ratio = lightDistance / range; + let cutoff = max(0.0, 1.0 - ratio * ratio * ratio * ratio); + var attenuation = cutoff * cutoff / max(lightDistance * lightDistance, 0.01); + if light.directionOuter.w >= 0.0 { + let cone = dot(normalize(light.directionOuter.xyz), -direction); + attenuation *= smoothstep(light.directionOuter.w, light.innerShadow.x, cone); + } + if attenuation <= 0.0 { + continue; + } + let contribution = evaluateDirect( + normal, viewDirection, direction, baseColor, metallic, roughness, + light.radianceIntensity.rgb * max(light.radianceIntensity.w, 0.0) * attenuation + ); + // The shadow lookup (nine depth taps) only scales the contribution, so a light that adds nothing here + // (facing away, or behind the surface) never needs it. + if max(contribution.r, max(contribution.g, contribution.b)) <= 0.0 { + continue; + } + var contact = 1.0; + if light.innerShadow.w >= 0.0 { + contact = contactVisibility(normal, direction, lightDistance); + } + result += contribution * localShadowVisibility(light, direction, normal) * contact; + } + return result; +} + +fn visibilityDither(pixel: vec2f) -> f32 { + let x = u32(floor(pixel.x)) % 4u; + let y = u32(floor(pixel.y)) % 4u; + var values = array(0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); + return (values[y * 4u + x] + 0.5) / 16.0; +} + +// Debug visualizations selected by u_ShadowAtlas.w (see DebugView3D.mainPassMode). Mode 0 is normal shading. +fn heatRamp(value: f32) -> vec3f { + let t = clamp(value, 0.0, 1.0); + return clamp(vec3f(1.5 - abs(4.0 * t - 3.0), 1.5 - abs(4.0 * t - 2.0), 1.5 - abs(4.0 * t - 1.0)), vec3f(0.0), vec3f(1.0)); +} + +fn cascadeTint(normalLight: f32) -> vec3f { + let shade = 0.35 + 0.65 * normalLight; + let splits = directional.u_CascadeSplits; + if Input.shadowFlags.x < 0.5 || directional.u_ShadowParameters.x < 0.5 { + return vec3f(0.5) * shade; + } + let depth = Input.viewPosition.z; + if depth > splits.z { + return vec3f(0.18) * shade; + } + let count = i32(splits.w); + let cascade = min(select(select(2, 1, depth <= splits.y), 0, depth <= splits.x), count - 1); + let tint = select(select(vec3f(0.25, 0.4, 0.95), vec3f(0.2, 0.8, 0.3), cascade == 1), vec3f(0.9, 0.2, 0.2), cascade == 0); + return tint * shade; +} + +fn reachingLightCount() -> f32 { + var count = select(0.0, 1.0, directional.u_LightDirectionIntensity.w > 0.0); + count += f32(i32(localLighting.u_LightCounts.x)); + for (var i = 0; i < 32; i++) { + if i >= i32(localLighting.u_LightCounts.y) { + break; + } + let light = localLighting.u_LocalLights[i]; + let delta = light.positionRange.xyz - Input.viewPosition; + let lightDistance = length(delta); + let range = light.positionRange.w; + if lightDistance >= range || range <= 0.0 { + continue; + } + if light.directionOuter.w >= 0.0 { + let cone = dot(normalize(light.directionOuter.xyz), -delta / max(lightDistance, 0.00001)); + if smoothstep(light.directionOuter.w, light.innerShadow.x, cone) <= 0.0 { + continue; + } + } + count += 1.0; + } + return count; +} + +// --------------------------------------------------------------------------------------------------------------------- +// Terrain surface +// --------------------------------------------------------------------------------------------------------------------- + +struct TerrainMaterialUniform { + u_TerrainGrid: vec4f, + u_TerrainTints: array, + u_TerrainSurfaces: array, + u_TerrainVariation: array, + u_TerrainVariationScale: array, +} + +struct TerrainNormalTransform { + u_TerrainNormalToWorld: mat4x4f, +} + +@if(ADA_TERRAIN) @group(0) @binding(51) var terrain: TerrainMaterialUniform; +@if(ADA_TERRAIN) @group(0) @binding(52) var terrainNormal: TerrainNormalTransform; + +struct TerrainTexel { + albedo: vec4f, + normal: vec3f, + orm: vec3f, + height: f32, +} + +@if(ADA_TERRAIN) +fn terrainHash(cell: vec2f, seed: u32) -> u32 { + // Wrapping before conversion also gives stable nonnegative integer hashes for negative local coordinates. + let wrapped = cell - vec2f(16777216.0) * floor(cell / vec2f(16777216.0)); + let p = vec2u(wrapped); + var h = seed ^ (p.x * 0x9e3779b9u) ^ (p.y * 0x85ebca6bu); + h ^= h >> 16u; + h *= 0x7feb352du; + h ^= h >> 15u; + h *= 0x846ca68bu; + return h ^ (h >> 16u); +} + +@if(ADA_TERRAIN) +fn terrainRead(layer: i32, uv: vec2f, dx: vec2f, dy: vec2f, transform: mat2x2f, offset: vec2f) -> TerrainTexel { + let q = transform * uv + offset; + let qx = transform * dx; + let qy = transform * dy; + let albedo = textureSampleGrad(u_TerrainAlbedo, u_BaseColorSampler, q, layer, qx, qy); + var normal = textureSampleGrad(u_TerrainNormal, u_BaseColorSampler, q, layer, qx, qy).xyz * 2.0 - vec3f(1.0); + // For q=M*uv, tangent-space gradients and normals return through transpose(M), including reflection handedness. + normal = vec3f(transpose(transform) * normal.xy, normal.z); + let orm = textureSampleGrad(u_TerrainORM, u_BaseColorSampler, q, layer, qx, qy); + return TerrainTexel( + vec4f(srgbToLinear(albedo.rgb), albedo.a), normal, orm.rgb, select(0.5, orm.a, terrain.u_TerrainVariationScale[layer].w > 0.5) + ); +} + +@if(ADA_TERRAIN) +fn terrainMacro(coordinate: vec2f, seed: u32) -> f32 { + let cell = floor(coordinate); + let f = smoothstep(vec2f(0.0), vec2f(1.0), fract(coordinate)); + let a = f32(terrainHash(cell, seed) & 65535u) / 65535.0; + let b = f32(terrainHash(cell + vec2f(1.0, 0.0), seed) & 65535u) / 65535.0; + let c = f32(terrainHash(cell + vec2f(0.0, 1.0), seed) & 65535u) / 65535.0; + let d = f32(terrainHash(cell + vec2f(1.0), seed) & 65535u) / 65535.0; + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +@if(ADA_TERRAIN) +fn terrainCoarsest(layer: i32) -> TerrainTexel { + let q = vec2f(0.5, 0.5); + let albedo = textureSampleLevel(u_TerrainAlbedo, u_BaseColorSampler, q, layer, f32(textureNumLevels(u_TerrainAlbedo) - 1u)); + var normal = textureSampleLevel(u_TerrainNormal, u_BaseColorSampler, q, layer, f32(textureNumLevels(u_TerrainNormal) - 1u)).xyz * 2.0 - vec3f(1.0); + let orm = textureSampleLevel(u_TerrainORM, u_BaseColorSampler, q, layer, f32(textureNumLevels(u_TerrainORM) - 1u)); + let flags = u32(terrain.u_TerrainVariation[layer].x); + if (flags & 16u) != 0u { + if (flags & 5u) != 0u { + normal.x = 0.0; + } + if (flags & 6u) != 0u { + normal.y = 0.0; + } + } + return TerrainTexel( + vec4f(srgbToLinear(albedo.rgb), albedo.a), normal, orm.rgb, select(0.5, orm.a, terrain.u_TerrainVariationScale[layer].w > 0.5) + ); +} + +@if(ADA_TERRAIN) +fn terrainMacroColor(layer: i32, input: TerrainTexel, localPosition: vec2f) -> TerrainTexel { + var value = input; + let settings = terrain.u_TerrainVariation[layer]; + if settings.w > 0.0 { + let seed = u32(settings.y) | (u32(settings.z) << 16u); + let macroValue = terrainMacro(localPosition / terrain.u_TerrainVariationScale[layer].y, seed ^ 0x68bc21ebu); + let tinted = clamp(value.albedo.rgb * (1.0 + (macroValue * 2.0 - 1.0) * settings.w), vec3f(0.0), vec3f(1.0)); + value.albedo = vec4f(tinted, value.albedo.a); + } + return value; +} + +@if(ADA_TERRAIN) +fn terrainSample(layer: i32, uv: vec2f, dx: vec2f, dy: vec2f, localPosition: vec2f) -> TerrainTexel { + let settings = terrain.u_TerrainVariation[layer]; + let flags = u32(settings.x); + if terrain.u_TerrainVariationScale[layer].z > 0.5 { + return terrainMacroColor(layer, terrainCoarsest(layer), localPosition); + } + if (flags & 16u) == 0u { + return terrainRead(layer, uv, dx, dy, mat2x2f(1.0, 0.0, 0.0, 1.0), vec2f(0.0)); + } + let seed = u32(settings.y) | (u32(settings.z) << 16u); + let coordinate = uv / terrain.u_TerrainVariationScale[layer].x; + let cell = floor(coordinate); + let f = smoothstep(vec2f(0.0), vec2f(1.0), fract(coordinate)); + var result = TerrainTexel(vec4f(0.0), vec3f(0.0), vec3f(0.0), 0.0); + for (var corner = 0; corner < 4; corner++) { + let x = corner & 1; + let y = corner >> 1u; + let weight = select(f.x, 1.0 - f.x, x == 0) * select(f.y, 1.0 - f.y, y == 0); + let random = terrainHash(cell + vec2f(f32(x), f32(y)), seed); + let flipU = select(1.0, -1.0, (flags & 1u) != 0u && (random & 1u) != 0u); + let flipV = select(1.0, -1.0, (flags & 2u) != 0u && (random & 2u) != 0u); + var transform = mat2x2f(flipU, 0.0, 0.0, flipV); + let rotation = select(0u, (random >> 2u) & 3u, (flags & 4u) != 0u); + if rotation == 1u { + transform = mat2x2f(0.0, 1.0, -1.0, 0.0) * transform; + } + if rotation == 2u { + transform = transform * -1.0; + } + if rotation == 3u { + transform = mat2x2f(0.0, -1.0, 1.0, 0.0) * transform; + } + let offset = select(vec2f(0.0), vec2f(f32((random >> 8u) & 255u), f32((random >> 16u) & 255u)) / 256.0, (flags & 8u) != 0u); + let sampleValue = terrainRead(layer, uv, dx, dy, transform, offset); + result.albedo += sampleValue.albedo * weight; + result.normal += sampleValue.normal * weight; + result.orm += sampleValue.orm * weight; + result.height += sampleValue.height * weight; + } + return terrainMacroColor(layer, result, localPosition); +} + +@if(ADA_TERRAIN) +fn terrainProjected(layer: i32, position: vec3f, dx: vec3f, dy: vec3f, geometric: vec3f, macroPosition: vec2f) -> TerrainTexel { + var weights = pow(abs(geometric), vec3f(4.0)); + weights /= max(weights.x + weights.y + weights.z, 0.00001); + var result = TerrainTexel(vec4f(0.0), vec3f(0.0), vec3f(0.0), 0.0); + for (var axis = 0; axis < 3; axis++) { + let weight = weights[axis]; + if weight <= 0.00001 { + continue; + } + var uv = position.xy; + var gradientX = dx.xy; + var gradientY = dy.xy; + if axis == 0 { + uv = position.zy; + gradientX = dx.zy; + gradientY = dy.zy; + } else if axis == 1 { + uv = position.xz; + gradientX = dx.xz; + gradientY = dy.xz; + } + let value = terrainSample(layer, uv, gradientX, gradientY, macroPosition); + let mapped = select(vec3f(0.0, 0.0, 1.0), normalize(value.normal), length(value.normal) > 0.00001); + // Whiteout correction: every flat-map axis produces the same original geometric normal. + var projected: vec3f; + if axis == 0 { + projected = vec3f(mapped.z * geometric.x, mapped.y + geometric.y, mapped.x + geometric.z); + } else if axis == 1 { + projected = vec3f(mapped.x + geometric.x, mapped.z * geometric.y, mapped.y + geometric.z); + } else { + projected = vec3f(mapped.x + geometric.x, mapped.y + geometric.y, mapped.z * geometric.z); + } + result.albedo += value.albedo * weight; + result.normal += projected * weight; + result.orm += value.orm * weight; + result.height += value.height * weight; + } + result.normal = select(geometric, normalize(result.normal), length(result.normal) > 0.00001); + return result; +} + +struct AdaPBRSurface { + baseColor: vec4f, + normal: vec3f, + roughness: f32, + metallic: f32, + opacity: f32, + occlusion: f32, + emission: vec3f, +} + +@if(ADA_TERRAIN) +fn ada_material_surface() -> AdaPBRSurface { + let grid0 = terrain.u_TerrainGrid; + // Stable IDs are mapped to this generation's array slots on upload. Never interpolate indices. + let volumeField = grid0.z < 0.0; + var grid = select(max((Input.textureCoordinate - grid0.xy) * grid0.z, vec2f(0.0)), Input.textureCoordinate1, volumeField); + let extent = vec2i(textureDimensions(u_MetallicRoughnessTexture, 0)) - vec2i(1); + grid = min(grid, vec2f(extent)); + let start = select(vec2i(floor(grid)), vec2i(floor(grid / 2.0)) * 2, volumeField); + let fraction = select(fract(grid), clamp(grid - vec2f(start), vec2f(0.0), vec2f(1.0)), volumeField); + // Binding17 is a terrain-only vertical atlas: premixed tint/premultiplied roughness, then mask. + // The shared neutral1x2 atlas keeps unpainted tiles allocation-free. + let paintSize = vec2i(textureDimensions(u_HeightTexture, 0)); + let paintExtent = vec2i(paintSize.x, max(paintSize.y / 2, 1)); + var paintedSurface = vec4f(0.0); + var paintedRoughnessMask = 0.0; + var contributions: array; + for (var z = 0; z < 2; z++) { + for (var x = 0; x < 2; x++) { + if volumeField && x == 1 && z == 1 { + continue; + } + let at = min(start + vec2i(x, z), extent); + let ids = vec4i(round(textureLoad(u_MetallicRoughnessTexture, at, 0) * 255.0)); + let weights = textureLoad(u_NormalTexture, at, 0); + var corner: f32; + if volumeField { + corner = select(select(max(0.0, 1.0 - fraction.x - fraction.y), fraction.y, z == 1), fraction.x, x == 1); + } else { + corner = select(fraction.x, 1.0 - fraction.x, x == 0) * select(fraction.y, 1.0 - fraction.y, z == 0); + } + let paintAt = min(at, paintExtent - vec2i(1)); + paintedSurface += textureLoad(u_HeightTexture, paintAt, 0) * corner; + paintedRoughnessMask += textureLoad(u_HeightTexture, paintAt + vec2i(0, paintExtent.y), 0).r * corner; + for (var channel = 0; channel < 4; channel++) { + contributions[clamp(ids[channel], 0, 31)] += weights[channel] * corner; + } + } + } + var tint = vec4f(0.0); + let geometric = normalize(Input.viewNormal); + let localPosition = vec3f(Input.textureCoordinate.x, Input.viewTangent.w, Input.textureCoordinate.y); + let localGeometric = normalize(Input.viewTangent.xyz); + // Derivatives are evaluated unconditionally (at the top of the fragment stage), before per-layer and axis selection. + let localGradientX = g_LocalPositionDx; + let localGradientY = g_LocalPositionDy; + let planarFrame = cotangentFrame(geometric, g_UVDx, g_UVDy); + let localToViewNormal = inverse3(upperLeft(ibl.u_EnvironmentInverseView)) * upperLeft(terrainNormal.u_TerrainNormalToWorld); + let normalTransformScale = max(length(localToViewNormal * localGeometric), 0.00001); + var surface = vec2f(0.0); + var normalSum = vec3f(0.0); + var occlusion = 0.0; + var total = 0.0; + var layerTexels: array; + var layerNormals: array; + var layerWeights: array; + var layerSlots: array; + var layerCount = 0; + // Bilinear corners can introduce sixteen IDs; resolve the strongest four before shading. + for (var outputSlot = 0; outputSlot < 4; outputSlot++) { + var selected = 0; + for (var slot = 1; slot < i32(grid0.w); slot++) { + if contributions[slot] > contributions[selected] { + selected = slot; + } + } + let weight = contributions[selected]; + if weight <= 0.0 { + break; + } + let coarse = terrain.u_TerrainVariationScale[selected].z > 0.5; + let scale = select(terrain.u_TerrainSurfaces[selected].z, 1.0, coarse); + var texel: TerrainTexel; + var layerNormal: vec3f; + if terrain.u_TerrainSurfaces[selected].w > 0.5 { + texel = terrainProjected( + selected, + select(localPosition / scale, vec3f(0.0), coarse), + select(localGradientX / scale, vec3f(0.0), coarse), + select(localGradientY / scale, vec3f(0.0), coarse), + localGeometric, + Input.textureCoordinate + ); + // Preserve the vertex-normalized/interpolated geometric baseline; transform only the map perturbation. + layerNormal = geometric + localToViewNormal * (texel.normal - localGeometric) / normalTransformScale; + } else { + texel = terrainSample( + selected, + select(Input.textureCoordinate / scale, vec2f(0.0), coarse), + select(g_UVDx / scale, vec2f(0.0), coarse), + select(g_UVDy / scale, vec2f(0.0), coarse), + Input.textureCoordinate + ); + layerNormal = planarFrame * texel.normal; + } + layerTexels[outputSlot] = texel; + layerNormals[outputSlot] = layerNormal; + layerWeights[outputSlot] = weight; + layerSlots[outputSlot] = selected; + layerCount += 1; + total += weight; + contributions[selected] = 0.0; + } + let baseTotal = max(total, 0.00001); + total = 0.0; + for (var index = 0; index < layerCount; index++) { + var weight = layerWeights[index] / baseTotal; + // A bounded positive bias preserves equal-height ratios and the continuous zero-weight limit. + // Missing maps supply exactly 0.5, so ordinary blending is unchanged (factor 1). + weight *= exp2(8.0 * (layerTexels[index].height - 0.5)); + let selected = layerSlots[index]; + let texel = layerTexels[index]; + tint += terrain.u_TerrainTints[selected] * texel.albedo * weight; + normalSum += layerNormals[index] * weight; + surface += terrain.u_TerrainSurfaces[selected].xy * texel.orm.gb * weight; + occlusion += texel.orm.r * weight; + total += weight; + } + total = max(total, 0.00001); + var normal = select(geometric, normalize(normalSum), length(normalSum) > 0.00001); + if !surfaceFrontFacing() && pbr.u_SurfaceFlags.x > 0.5 { + normal = -normal; + } + var baseColor = Input.color * tint / total; + baseColor = vec4f(baseColor.rgb * paintedSurface.rgb, baseColor.a); + let roughness = surface.x / total * (1.0 - paintedRoughnessMask) + paintedSurface.a; + return AdaPBRSurface( + baseColor, normal, clamp(roughness, 0.04, 1.0), clamp(surface.y / total, 0.0, 1.0), 1.0, clamp(occlusion / total, 0.0, 1.0), vec3f(0.0) + ); +} + +@if(!ADA_TERRAIN && ADA_SHADER_GRAPH) +fn ada_material_surface() -> AdaPBRSurface { + let graph = ada_graph_evaluate(Input.textureCoordinate); + var baseColor = Input.color * graph.color; + baseColor.a *= graph.alpha; + if pbr.u_SurfaceProperties.w > 0.5 && pbr.u_SurfaceProperties.w < 1.5 && baseColor.a < pbr.u_SurfaceProperties.z { + discard; + } + let opacity = select(1.0, baseColor.a, pbr.u_SurfaceProperties.w > 1.5); + let geometric = normalize(Input.viewNormal); + let tangentNormal = select(vec3f(0.0, 0.0, 1.0), normalize(graph.normal), length(graph.normal) > 0.00001); + var normal: vec3f; + if Input.textureFlags.w > 0.5 { + let tangent = normalize(Input.viewTangent.xyz - geometric * dot(geometric, Input.viewTangent.xyz)); + let bitangent = normalize(cross(geometric, tangent)) * Input.viewTangent.w; + normal = normalize(mat3x3f(tangent, bitangent, geometric) * tangentNormal); + } else { + normal = normalize(cotangentFrame(geometric, g_UVDx, g_UVDy) * tangentNormal); + } + if !surfaceFrontFacing() && pbr.u_SurfaceFlags.x > 0.5 { + normal = -normal; + } + return AdaPBRSurface( + baseColor, normal, clamp(graph.roughness, 0.04, 1.0), clamp(graph.metallic, 0.0, 1.0), opacity, + clamp(graph.occlusion, 0.0, 1.0), max(graph.emission, vec3f(0.0)) + ); +} + +@if(!ADA_TERRAIN && !ADA_SHADER_GRAPH) +fn ada_material_surface() -> AdaPBRSurface { + var baseColor = Input.color; + if Input.textureFlags.x > 0.5 { + let sampledBaseColor = sampleMaterial(u_BaseColorTexture, u_BaseColorSampler, materialUV(pbr.u_UVSets.x, 0)); + baseColor *= vec4f(srgbToLinear(sampledBaseColor.rgb), sampledBaseColor.a); + } + + if pbr.u_SurfaceProperties.w > 0.5 && pbr.u_SurfaceProperties.w < 1.5 && baseColor.a < pbr.u_SurfaceProperties.z { + discard; + } + let opacity = select(1.0, baseColor.a, pbr.u_SurfaceProperties.w > 1.5); + var roughness = Input.material.x; + var metallic = Input.material.y; + if Input.textureFlags.y > 0.5 { + let metallicRoughness = sampleMaterial(u_MetallicRoughnessTexture, u_MetallicRoughnessSampler, materialUV(pbr.u_UVSets.y, 1)); + roughness = clamp(roughness * metallicRoughness.g, 0.04, 1.0); + metallic = clamp(metallic * metallicRoughness.b, 0.0, 1.0); + } + + var normal = materialNormal(); + if !surfaceFrontFacing() && pbr.u_SurfaceFlags.x > 0.5 { + normal = -normal; + } + return AdaPBRSurface(baseColor, normal, roughness, metallic, opacity, 1.0, vec3f(0.0)); +} + +var g_LocalPositionDx: vec3f; +var g_LocalPositionDy: vec3f; + +@fragment +fn flat3d_fragment(input: Flat3DVertex, @builtin(front_facing) frontFacing: bool) -> Flat3DOutput { + Input = input; + g_FrontFacing = frontFacing; + // Derivatives in uniform control flow; the shading code below reads these instead of calling dpdx/dpdy. + g_PositionDx = dpdx(input.viewPosition); + g_PositionDy = dpdy(input.viewPosition); + g_UVDx = dpdx(input.textureCoordinate); + g_UVDy = dpdy(input.textureCoordinate); + g_UV1Dx = dpdx(input.textureCoordinate1); + g_UV1Dy = dpdy(input.textureCoordinate1); + g_LocalPositionDx = dpdx(vec3f(input.textureCoordinate.x, input.viewTangent.w, input.textureCoordinate.y)); + g_LocalPositionDy = dpdy(vec3f(input.textureCoordinate.x, input.viewTangent.w, input.textureCoordinate.y)); + + var out: Flat3DOutput; + if pbr.u_SurfaceFlags.z > 0.5 { + g_ParallaxDelta = parallaxDelta(); + } + if Input.shadowFlags.w > 0.0 && visibilityDither(input.position.xy) < Input.shadowFlags.w { + discard; + } + @if(!ADA_WIREFRAME) { + if pbr.u_SurfaceFlags.x < 0.5 && !surfaceFrontFacing() { + discard; + } + } + let surface = ada_material_surface(); + @if(ADA_WIREFRAME) { + out.depth = input.position.z; + out.color = vec4f(0.88, 0.93, 1.0, 1.0); + out.normalRoughness = vec4f(surface.normal, surface.roughness); + out.viewPositionMetallic = vec4f(Input.viewPosition, surface.metallic); + out.indirectLighting = vec4f(0.0); + @if(ADA_HYBRID_REFLECTION) { + out.reflectionResponse = vec4f(0.0); + } + @if(ADA_DIFFUSE_SURFACE) { + out.diffuseSurface = vec4f(0.0); + } + return out; + } + let baseColor = surface.baseColor; + let opacity = surface.opacity; + let roughness = surface.roughness; + let metallic = surface.metallic; + let normal = surface.normal; + // Shading layers: each factor is multiplied by its texture when the material has one. + g_Dielectric = pbr.u_Dielectric.rgb; + @if(ADA_SPECULAR_TEXTURE) { + g_Dielectric *= sampleMaterial(u_SpecularTexture, u_BaseColorSampler, layerUV(6)).a; + } + @if(ADA_SPECULAR_COLOR_TEXTURE) { + g_Dielectric *= srgbToLinear(sampleMaterial(u_SpecularColorTexture, u_BaseColorSampler, layerUV(7)).rgb); + } + + g_Sheen = vec4f(max(pbr.u_Sheen.rgb, vec3f(0.0)), clamp(pbr.u_Sheen.w, 0.07, 1.0)); + @if(ADA_SHEEN_COLOR_TEXTURE) { + g_Sheen = vec4f(g_Sheen.rgb * srgbToLinear(sampleMaterial(u_SheenColorTexture, u_BaseColorSampler, layerUV(3)).rgb), g_Sheen.w); + } + @if(ADA_SHEEN_ROUGHNESS_TEXTURE) { + g_Sheen.w = clamp(pbr.u_Sheen.w * sampleMaterial(u_SheenRoughnessTexture, u_BaseColorSampler, layerUV(4)).a, 0.07, 1.0); + } + + g_Anisotropy = clamp(pbr.u_Anisotropy.x, 0.0, 1.0); + var anisotropyDirection = vec2f(1.0, 0.0); + @if(ADA_ANISOTROPY_TEXTURE) { + // Red and green hold the direction in tangent space, blue scales the strength. + let anisotropySample = sampleMaterial(u_AnisotropyTexture, u_BaseColorSampler, layerUV(5)).rgb; + g_Anisotropy = clamp(g_Anisotropy * anisotropySample.b, 0.0, 1.0); + let sampledDirection = anisotropySample.rg * 2.0 - vec2f(1.0); + if dot(sampledDirection, sampledDirection) > 0.0001 { + anisotropyDirection = normalize(sampledDirection); + } + } + if hasAnisotropy() { + var authored = Input.textureFlags.w > 0.5; + @if(ADA_TERRAIN) { + authored = false; // This slot packs local terrain data, not an authored tangent. + } + let frameUV = materialUV(pbr.u_UVSets.x, 0); + var tangent = select(cotangentFrame(normalize(Input.viewNormal), frameUV.dx, frameUV.dy)[0], Input.viewTangent.xyz, authored); + tangent = normalize(tangent - normal * dot(normal, tangent)); + let bitangent = normalize(cross(normal, tangent)) * select(1.0, Input.viewTangent.w, authored); + // The rotation turns the direction, whether it comes from the texture or is the tangent itself. + let rotationCosine = cos(pbr.u_Anisotropy.y); + let rotationSine = sin(pbr.u_Anisotropy.y); + let turned = vec2f( + rotationCosine * anisotropyDirection.x - rotationSine * anisotropyDirection.y, + rotationSine * anisotropyDirection.x + rotationCosine * anisotropyDirection.y + ); + g_AnisotropyT = normalize(turned.x * tangent + turned.y * bitangent); + g_AnisotropyB = normalize(cross(normal, g_AnisotropyT)); + } + + g_Clearcoat = vec2f(pbr.u_Coat.x, clamp(pbr.u_Coat.y, 0.04, 1.0)); + g_CoatNormal = normalize(Input.viewNormal); + if !surfaceFrontFacing() && pbr.u_SurfaceFlags.x > 0.5 { + g_CoatNormal = -g_CoatNormal; + } + @if(ADA_CLEARCOAT_TEXTURE) { + g_Clearcoat.x *= sampleMaterial(u_ClearcoatTexture, u_BaseColorSampler, layerUV(0)).r; + } + @if(ADA_CLEARCOAT_ROUGHNESS_TEXTURE) { + g_Clearcoat.y = clamp(pbr.u_Coat.y * sampleMaterial(u_ClearcoatRoughnessTexture, u_BaseColorSampler, layerUV(1)).g, 0.04, 1.0); + } + @if(ADA_CLEARCOAT_NORMAL_TEXTURE) { + let coatUV = layerUV(2); + var coatTangentNormal = sampleMaterial(u_ClearcoatNormalTexture, u_BaseColorSampler, coatUV).xyz * 2.0 - vec3f(1.0); + coatTangentNormal = vec3f(coatTangentNormal.xy * pbr.u_LayerParameters.x, coatTangentNormal.z); + g_CoatNormal = normalize(cotangentFrame(g_CoatNormal, coatUV.dx, coatUV.dy) * coatTangentNormal); + } + let viewDirection = normalize(-Input.viewPosition); + let lightDirection = normalize(directional.u_LightDirectionIntensity.xyz); + out.depth = input.position.z; + + if Input.shadowFlags.y > 0.0 { + // The atmosphere is drawn before the planet. Keep its haze in the color target without preventing the opaque + // planet from drawing. + out.depth = 1.0; + + let dayFacing = smoothstep(-0.3, 0.55, dot(normal, lightDirection)); + let fresnelPower = mix(Input.shadowFlags.y + 1.5, Input.shadowFlags.y * 0.58, dayFacing); + let fresnel = pow(clamp(1.0 - abs(dot(normal, viewDirection)), 0.0, 1.0), max(fresnelPower, 0.5)); + let alpha = clamp(baseColor.a * Input.shadowFlags.z * fresnel * mix(0.28, 1.0, dayFacing), 0.0, 0.88); + let atmosphereColor = baseColor.rgb * mix(0.72, 1.2, dayFacing); + + out.color = vec4f(atmosphereColor, alpha); + out.indirectLighting = vec4f(0.0, 0.0, 0.0, alpha); + @if(ADA_HYBRID_REFLECTION) { + out.reflectionResponse = vec4f(0.0, 0.0, 0.0, alpha); + } + out.normalRoughness = vec4f(normal, 1.0); + out.viewPositionMetallic = vec4f(Input.viewPosition, 0.0); + return out; + } + + let normalLight = max(dot(normal, lightDirection), 0.0); + let normalView = max(dot(normal, viewDirection), 0.0); + + let radiance = max(directional.u_LightRadianceAmbient.rgb, vec3f(0.0)) * max(directional.u_LightDirectionIntensity.w, 0.0); + // The sun term is multiplied by n.l, so surfaces facing away from it skip the cascade lookups. The clearcoat + // follows the geometric normal and can still be lit there. + let coatLit = hasClearcoat(); + var shadowVisibility = 1.0; + if normalLight > 0.0 || coatLit { + shadowVisibility = directionalShadow(normal, lightDirection); + } + if surfaceLighting.u_ContactQuality.z > 0.5 { + shadowVisibility *= contactVisibility(normal, lightDirection, surfaceLighting.u_Contact.y); + } + var distanceFieldVisibility = 1.0; + let sdfTrace = distanceField.u_SDFTrace; + if sdfTrace.w > 0.5 && surfaceLighting.u_ContactQuality.z > 0.5 && Input.shadowFlags.x > 0.5 + && (normalLight > 0.0 || coatLit) && (shadowVisibility > 0.0 || sdfTrace.w > 1.5) { + var sdfNormal = Input.viewNormal; + if !surfaceFrontFacing() && pbr.u_SurfaceFlags.x > 0.5 { + sdfNormal = -sdfNormal; + } + let inverseView = upperLeft(ibl.u_EnvironmentInverseView); + distanceFieldVisibility = adaDistanceFieldVisibility( + Input.worldPosition, normalize(inverseView * sdfNormal), normalize(inverseView * lightDirection), + distanceField.u_SDFCountsDistance.w + ); + shadowVisibility *= distanceFieldVisibility; + } + var directLighting = evaluateDirect(normal, viewDirection, lightDirection, baseColor.rgb, metallic, roughness, radiance) * shadowVisibility; + directLighting += additionalLighting(normal, viewDirection, baseColor.rgb, metallic, roughness); + var ambient = baseColor.rgb * (1.0 - metallic) * max(directional.u_LightRadianceAmbient.w, 0.0); + let baked = adaBakedDiffuse( + Input.worldPosition, normalize(upperLeft(ibl.u_EnvironmentInverseView) * normal), Input.textureCoordinate1, + pbr.u_LightmapScaleOffset, pbr.u_BakedParameters + ); + var bakedPart = vec3f(0.0); + if ibl.u_IBLParameters.x > 0.5 { + let environment = environmentLighting(normal, viewDirection, baseColor.rgb, metallic, roughness, baked); + ambient = environment.color; + bakedPart = environment.bakedPart; + } else if coatLit { + ambient *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); + } + @if(ADA_LIGHTMAP || ADA_IRRADIANCE_GRID) { + if ibl.u_IBLParameters.x < 0.5 { + let f0 = mix(g_Dielectric, baseColor.rgb, metallic); + let fresnel = f0 + (max(vec3f(1.0 - roughness), f0) - f0) * pow(1.0 - normalView, 5.0); + var bakedAmbient = (vec3f(1.0) - fresnel) * (1.0 - metallic) * baseColor.rgb * baked.rgb; + if hasSheen() { + bakedAmbient = bakedAmbient * sheenTransmission() + g_Sheen.rgb * SHEEN_ALBEDO * baked.rgb; + } + if coatLit { + bakedAmbient *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); + } + ambient = mix(ambient, bakedAmbient, baked.a); + bakedPart = bakedAmbient * baked.a; + } + } + var occlusion = 1.0; + @if(ADA_SHADER_GRAPH || ADA_TERRAIN) { + occlusion = surface.occlusion; + } + @if(!ADA_SHADER_GRAPH && !ADA_TERRAIN) { + if pbr.u_SurfaceFlags.y > 0.5 { + occlusion = sampleMaterial(u_OcclusionTexture, u_OcclusionSampler, materialUV(pbr.u_UVSets.w, 3)).r; + } + } + ambient *= mix(1.0, occlusion, pbr.u_SurfaceProperties.y); + @if(ADA_LIGHTMAP || ADA_IRRADIANCE_GRID) { + // Geometry visibility is already baked. Material AO still affects runtime environment specular. + ambient += bakedPart * (1.0 - mix(1.0, occlusion, pbr.u_SurfaceProperties.y)); + } + g_ReflectionResponse *= mix(1.0, occlusion, pbr.u_SurfaceProperties.y); + var emissiveVisibility = 1.0; + let emissiveLightThreshold = Input.material.w; + if emissiveLightThreshold >= 0.0 { + emissiveVisibility = 1.0 - smoothstep(emissiveLightThreshold, emissiveLightThreshold + 0.16, normalLight); + } + var emissive = vec3f(0.0); + @if(ADA_SHADER_GRAPH || ADA_TERRAIN) { + emissive = surface.emission * emissiveVisibility; + } + @if(!ADA_SHADER_GRAPH && !ADA_TERRAIN) { + emissive = srgbToLinear(sampleMaterial(u_EmissiveTexture, u_EmissiveSampler, materialUV(pbr.u_Emission.x, 4)).rgb) + * pbr.u_EmissiveFactor.rgb * Input.material.z * emissiveVisibility; + } + + out.color = vec4f(directLighting + ambient + emissive, opacity); + if sdfTrace.w > 1.5 { + out.color = vec4f(mix(vec3f(0.6, 0.025, 0.015), vec3f(0.025, 0.65, 0.08), distanceFieldVisibility), opacity); + } + @if(ADA_DIFFUSE_SURFACE) { + let giF0 = mix(g_Dielectric, baseColor.rgb, metallic); + let giFresnel = giF0 + (max(vec3f(1.0 - roughness), giF0) - giF0) * pow(1.0 - max(dot(normal, viewDirection), 0.0), 5.0); + var diffuseWeight = (vec3f(1.0) - giFresnel) * (1.0 - metallic); + if coatLit { + diffuseWeight *= coatTransmission(max(dot(g_CoatNormal, viewDirection), 0.0)); + } + out.diffuseSurface = vec4f(clamp(baseColor.rgb * diffuseWeight, vec3f(0.0), vec3f(1.0)), opacity); + } + let debugMode = directional.u_ShadowAtlas.w; + if debugMode > 0.5 { + var debugColor = vec3f(0.3); + if debugMode < 1.5 { + debugColor = adaLinearToSrgb(baseColor.rgb); + } else if debugMode < 2.5 { + debugColor = cascadeTint(normalLight); + } else if debugMode < 3.5 { + debugColor = heatRamp(reachingLightCount() / 8.0); + } + out.color = vec4f(select(debugColor, vec3f(0.025), debugMode > 4.5), opacity); + } + // Transparent surfaces attenuate the opaque indirect buffer but do not contribute to it. + let transparent = pbr.u_SurfaceProperties.w > 1.5; + out.indirectLighting = vec4f(select(ambient, vec3f(0.0), transparent), opacity); + @if(ADA_HYBRID_REFLECTION) { + out.reflectionResponse = vec4f(select(g_ReflectionResponse, vec3f(0.0), transparent), opacity); + } + out.normalRoughness = vec4f(normal, roughness); + out.viewPositionMetallic = vec4f(Input.viewPosition, metallic); + @if(ADA_PATH_TRACING_SURFACE && ADA_DIFFUSE_SURFACE && 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. + out.diffuseSurface = vec4f(clamp(baseColor.rgb, vec3f(0.0), vec3f(1.0)), opacity); + out.reflectionResponse = vec4f(select(emissive, vec3f(0.0), transparent), opacity); + } + return out; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/fullscreen.wgsl b/Sources/AdaCorePipelines/Assets/Shaders/fullscreen.wgsl new file mode 100644 index 000000000..c416bd6bf --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/fullscreen.wgsl @@ -0,0 +1,10 @@ +struct FullscreenVertex { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +fn fullscreenVertex(index: u32) -> FullscreenVertex { + let uv = vec2f(f32(index >> 1u), f32(index & 1u)) * 2.0; + let position = vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.0, 1.0); + return FullscreenVertex(position, uv); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/fxaa_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/fxaa_3d.wesl new file mode 100644 index 000000000..0b73449ac --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/fxaa_3d.wesl @@ -0,0 +1,48 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct FXAAUniform { + inverseSize: vec4f, +} + +@group(0) @binding(0) var sceneColor: texture_2d; +@group(0) @binding(1) var linearSampler: sampler; +@group(0) @binding(2) var fxaa: FXAAUniform; + +@vertex +fn fxaa_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn sampleColor(uv: vec2f) -> vec3f { + // The scene color is a single-mip render target. + return textureSampleLevel(sceneColor, linearSampler, uv, 0.0).rgb; +} + +@fragment +fn fxaa_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let uv = input.uv; + let pixel = fxaa.inverseSize.xy; + let center = sampleColor(uv); + let nw = sampleColor(uv + vec2f(-1.0, -1.0) * pixel); + let ne = sampleColor(uv + vec2f(1.0, -1.0) * pixel); + let sw = sampleColor(uv + vec2f(-1.0, 1.0) * pixel); + let se = sampleColor(uv + vec2f(1.0, 1.0) * pixel); + let luma = vec3f(0.299, 0.587, 0.114); + let middle = dot(center, luma); + let a = dot(nw, luma); + let b = dot(ne, luma); + let c = dot(sw, luma); + let d = dot(se, luma); + let minimum = min(middle, min(min(a, b), min(c, d))); + let maximum = max(middle, max(max(a, b), max(c, d))); + if maximum - minimum < max(0.0312, maximum * 0.125) { + return vec4f(center, 1.0); + } + let edge = vec2f(-((a + b) - (c + d)), (a + c) - (b + d)); + let reduce = max((a + b + c + d) * 0.03125, 0.0078125); + let direction = clamp(edge / (min(abs(edge.x), abs(edge.y)) + reduce), vec2f(-8.0), vec2f(8.0)) * pixel; + let first = 0.5 * (sampleColor(uv + direction * (-1.0 / 6.0)) + sampleColor(uv + direction * (1.0 / 6.0))); + let second = first * 0.5 + 0.25 * (sampleColor(uv - direction * 0.5) + sampleColor(uv + direction * 0.5)); + let secondLuma = dot(second, luma); + return vec4f(select(second, first, secondLuma < minimum || secondLuma > maximum), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/glass.wesl b/Sources/AdaCorePipelines/Assets/Shaders/glass.wesl new file mode 100644 index 000000000..e2cac5515 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/glass.wesl @@ -0,0 +1,218 @@ +import ada_engine::view::projectPosition; + +// WESL port of glass.glsl. Attribute and varying locations are unchanged. + +struct GlassVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) texCoordinate: vec2f, + @location(2) params0: vec4f, + @location(3) params1: vec4f, + @location(4) params2: vec4f, + @location(5) params3: vec4f, + @location(6) info0: vec4f, + @location(7) info1: vec4f, +} + +@group(0) @binding(0) var u_BackgroundTexture: texture_2d; +@group(0) @binding(1) var u_BackgroundSampler: sampler; + +@vertex +fn glass_vertex( + @location(0) position: vec4f, + @location(1) color: vec4f, + @location(2) texCoordinate: vec2f, + @location(3) params0: vec4f, // x=blurRadius, y=cornerRadius, z=tintStrength, w=edgeShadow + @location(4) params1: vec4f, // x=cornerRoundnessExponent, y=glassThickness, z=refractiveIndex, w=dispersionStrength + @location(5) params2: vec4f, // x=fresnelDistanceRange, y=fresnelIntensity, z=fresnelEdgeSharpness, w=glareDistanceRange + @location(6) params3: vec4f, // x=glareAngleConvergence, y=glareOppositeSideBias, z=glareIntensity, w=glareEdgeSharpness + @location(7) info0: vec4f, // x=halfWidth, y=halfHeight, z=scaleFactor, w=opacity + @location(8) info1: vec4f // x=glareDirectionOffset, yzw reserved +) -> GlassVertex { + return GlassVertex(projectPosition(position), color, texCoordinate, params0, params1, params2, params3, info0, info1); +} + +// A 9x9 bilinear kernel keeps the same blur footprint with 81 taps. +const kBlurGridRadius: i32 = 4; + +fn sdRoundedBox(p: vec2f, b: vec2f, r: f32) -> f32 { + let q = abs(p) - b + vec2f(r); + return min(max(q.x, q.y), 0.0) + length(max(q, vec2f(0.0))) - r; +} + +// GLSL smoothstep tolerates reversed edges (it evaluates the same polynomial); WGSL leaves them unspecified. +fn smoothstepAnyEdges(edge0: f32, edge1: f32, x: f32) -> f32 { + let t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +fn sampleBg(uv: vec2f) -> vec4f { + // Captures have one mip. Explicit LOD also keeps optical branches valid + // under WebGPU derivative-uniformity validation. + return textureSampleLevel(u_BackgroundTexture, u_BackgroundSampler, uv, 0.0); +} + +fn sampleBlurredChannel(uv: vec2f, texelSize: vec2f, radius: f32, channel: i32) -> f32 { + if radius < 0.5 { + return sampleBg(uv)[channel]; + } + var value = 0.0; + var totalWeight = 0.0; + let sigma = max(radius * 0.42, 1.0); + let stepPx = max(radius / f32(kBlurGridRadius), 1.0); + + for (var y = -kBlurGridRadius; y <= kBlurGridRadius; y++) { + for (var x = -kBlurGridRadius; x <= kBlurGridRadius; x++) { + let offsetPx = vec2f(f32(x), f32(y)) * stepPx; + let w = exp(-dot(offsetPx, offsetPx) / (2.0 * sigma * sigma)); + let offset = offsetPx * texelSize; + let sampleUV = clamp(uv + offset, vec2f(0.001), vec2f(0.999)); + value += sampleBg(sampleUV)[channel] * w; + totalWeight += w; + } + } + return value / totalWeight; +} + +fn sampleBlurredAll(uv: vec2f, texelSize: vec2f, radius: f32) -> vec3f { + if radius < 0.5 { + return sampleBg(uv).rgb; + } + var value = vec3f(0.0); + var totalWeight = 0.0; + let sigma = max(radius * 0.42, 1.0); + let stepPx = max(radius / f32(kBlurGridRadius), 1.0); + + for (var y = -kBlurGridRadius; y <= kBlurGridRadius; y++) { + for (var x = -kBlurGridRadius; x <= kBlurGridRadius; x++) { + let offsetPx = vec2f(f32(x), f32(y)) * stepPx; + let w = exp(-dot(offsetPx, offsetPx) / (2.0 * sigma * sigma)); + let offset = offsetPx * texelSize; + let sampleUV = clamp(uv + offset, vec2f(0.001), vec2f(0.999)); + value += sampleBg(sampleUV).rgb * w; + totalWeight += w; + } + } + return value / totalWeight; +} + +@fragment +fn glass_fragment(input: GlassVertex) -> @location(0) vec4f { + let blurRadius = input.params0.x; + let cornerRadius = input.params0.y; + let glassTintStr = saturate(input.params0.z); + let edgeShadowStr = saturate(input.params0.w); + + let glassThickness = max(0.0, input.params1.y); + let refractiveIndex = max(1.0, input.params1.z); + let dispersionStrength = saturate(input.params1.w); + + let halfW = input.info0.x; + let halfH = input.info0.y; + let scaleFactor = max(input.info0.z, 1.0); + let glassOpacity = saturate(input.info0.w); + + let bgTexSize = vec2f(textureDimensions(u_BackgroundTexture, 0)); + let texelSize = vec2f(1.0) / bgTexSize; + + // Local position from center (logical pixels). + let localPos = (input.texCoordinate - vec2f(0.5)) * 2.0 * vec2f(halfW, halfH); + let halfSize = vec2f(halfW, halfH); + + // --- SDF (simple rounded box, like OpenGlass) --- + let sdf = sdRoundedBox(localPos, halfSize, cornerRadius); + let edgeAA = max(length(vec2f(dpdx(sdf), dpdy(sdf))), 0.5); + if sdf > edgeAA { + discard; + } + + let distFromEdge = -sdf; + let effectiveSize = min(halfW * 2.0, halfH * 2.0); + + // --- Edge-band refraction (OpenGlass approach) --- + let edgeBandMul = 0.1; + let edgeBand = effectiveSize * edgeBandMul; + var edgeFactor = 1.0 - smoothstep(0.0, edgeBand, distFromEdge); + edgeFactor = edgeFactor * edgeFactor * edgeFactor * 2.0; + + // Refraction direction from SDF gradient (smooth, no cross-hair artifact). + let kGradEps = 1.0; + let sdfXp = sdRoundedBox(localPos + vec2f(kGradEps, 0.0), halfSize, cornerRadius); + let sdfXm = sdRoundedBox(localPos - vec2f(kGradEps, 0.0), halfSize, cornerRadius); + let sdfYp = sdRoundedBox(localPos + vec2f(0.0, kGradEps), halfSize, cornerRadius); + let sdfYm = sdRoundedBox(localPos - vec2f(0.0, kGradEps), halfSize, cornerRadius); + let sdfGrad = vec2f(sdfXp - sdfXm, sdfYp - sdfYm); + let gradLen = length(sdfGrad); + let toCenter = select(vec2f(0.0), -sdfGrad / gradLen, gradLen > 1e-4); + + // Zoom / magnification from glass thickness. + let zoom = 1.0 + glassThickness * 0.0015; + let normFromCenter = localPos / max(halfSize, vec2f(1e-4)); + let distNorm = length(normFromCenter); + let zoomFactor = max(0.0, 1.0 - distNorm * distNorm); + let zoomStrength = zoom - 1.0; + let minSide = min(halfW, halfH); + let magOffset = -normFromCenter * zoomFactor * zoomStrength * minSide; + + // Refraction and chromatic aberration strengths. + let refrStr = (refractiveIndex - 1.0) * 5.0; + let disp = edgeFactor * refrStr * edgeBand; + let chrome = edgeFactor * dispersionStrength * 4.0; + + let greenOff = toCenter * disp + magOffset; + + // --- Sample background --- + let blurPhysical = max(blurRadius * scaleFactor, 0.0); + let fragPx = input.position.xy; + + let redOff = toCenter * (disp + chrome) + magOffset; + let blueOff = toCenter * (disp - chrome) + magOffset; + + let redUV = clamp((fragPx + redOff * scaleFactor) / bgTexSize, vec2f(0.001), vec2f(0.999)); + let greenUV = clamp((fragPx + greenOff * scaleFactor) / bgTexSize, vec2f(0.001), vec2f(0.999)); + let blueUV = clamp((fragPx + blueOff * scaleFactor) / bgTexSize, vec2f(0.001), vec2f(0.999)); + + var color: vec3f; + // Regular/clear glass has no dispersion: all channels share one blur. + if chrome == 0.0 { + color = sampleBlurredAll(greenUV, texelSize, blurPhysical); + } else { + color = vec3f( + sampleBlurredChannel(redUV, texelSize, blurPhysical, 0), + sampleBlurredChannel(greenUV, texelSize, blurPhysical, 1), + sampleBlurredChannel(blueUV, texelSize, blurPhysical, 2) + ); + } + + // --- Luminance-adaptive tinting (OpenGlass light-mode path) --- + let lum = dot(color, vec3f(0.299, 0.587, 0.114)); + let tintAmount = max(lum, 0.08) * glassTintStr * mix(1.0, 0.35, lum); + color = vec3f(1.0) - (vec3f(1.0) - color) * (1.0 - tintAmount); + + // --- Shadow --- + let shadowScale = saturate(1.0 - lum * 0.8); + let overallShadow = 0.02 * shadowScale; + let edgeShadow = (1.0 - smoothstep(0.0, 20.0, distFromEdge)) * edgeShadowStr * shadowScale; + color = color * (1.0 - overallShadow - edgeShadow); + + // --- Anti-aliased alpha --- + let aa = saturate(0.5 - sdf / (edgeAA * 2.0)); + + // --- Thin directional border (OpenGlass style, softened) --- + let borderWidth = 1.5; + let borderInner = smoothstepAnyEdges(borderWidth, 0.2, distFromEdge); + let borderOuter = saturate(distFromEdge / (edgeAA * 0.8)); + let borderMask = borderInner * borderOuter * aa; + + let borderNorm = select(vec2f(0.0), sdfGrad / gradLen, gradLen > 1e-4); + let lightDir = normalize(vec2f(1.0, 1.0)); + var dirFactor = abs(dot(borderNorm, lightDir)); + dirFactor = dirFactor * dirFactor; + let borderLight = mix(0.35, 0.70, dirFactor) * mix(0.5, 1.0, lum); + color = mix(color, vec3f(1.0), borderMask * borderLight * 0.6); + + // Optional tint color overlay from vertex data. + color = mix(color, input.color.rgb, input.color.a * 0.3); + + return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), aa * glassOpacity); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/gradient.wesl b/Sources/AdaCorePipelines/Assets/Shaders/gradient.wesl new file mode 100644 index 000000000..65e27d04a --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/gradient.wesl @@ -0,0 +1,63 @@ +import ada_engine::view::projectPosition; + +struct GradientVertex { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +struct GradientUniform { + startPoint: vec2f, + endPoint: vec2f, + stopCount: i32, + colors: array, + locations: array, +} + +@group(0) @binding(0) var gradient: GradientUniform; + +@vertex +fn linear_gradient_vertex(@location(0) position: vec4f, @location(2) uv: vec2f) -> GradientVertex { + return GradientVertex(projectPosition(position), uv); +} + +fn stopLocation(index: i32) -> f32 { + return gradient.locations[index / 4][index % 4]; +} + +fn gradientProgress(uv: vec2f) -> f32 { + let axis = gradient.endPoint - gradient.startPoint; + let lengthSquared = dot(axis, axis); + if lengthSquared <= 0.000001 { + return 1.0; + } + return clamp(dot(uv - gradient.startPoint, axis) / lengthSquared, 0.0, 1.0); +} + +fn gradientColor(progress: f32) -> vec4f { + let count = clamp(gradient.stopCount, 1, 16); + var color = gradient.colors[0]; + var location = stopLocation(0); + for (var index = 1; index < count; index++) { + let nextColor = gradient.colors[index]; + let nextLocation = stopLocation(index); + if progress <= nextLocation { + let range = nextLocation - location; + if range <= 0.000001 { + return nextColor; + } + return mix(color, nextColor, clamp((progress - location) / range, 0.0, 1.0)); + } + color = nextColor; + location = nextLocation; + } + return color; +} + +@fragment +fn linear_gradient_fragment(@location(0) uv: vec2f) -> @location(0) vec4f { + let color = gradientColor(gradientProgress(uv)); + if color.a == 0.0 { + discard; + } + return color; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/line.wesl b/Sources/AdaCorePipelines/Assets/Shaders/line.wesl new file mode 100644 index 000000000..f0815d941 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/line.wesl @@ -0,0 +1,16 @@ +import ada_engine::view::projectPosition; + +struct LineVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, +} + +@vertex +fn line_vertex(@location(0) position: vec3f, @location(1) color: vec4f) -> LineVertex { + return LineVertex(projectPosition(vec4f(position, 1.0)), color); +} + +@fragment +fn line_fragment(input: LineVertex) -> @location(0) vec4f { + return input.color; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/local_volumetric_fog_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/local_volumetric_fog_3d.wesl new file mode 100644 index 000000000..6484ec67b --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/local_volumetric_fog_3d.wesl @@ -0,0 +1,175 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; +import package::volumetric_fog_common::{loadNearestShadow, fog, positionImage, nearestSampler, henyeyGreenstein, sunVisibility, sunShadowGap}; + +struct LocalLight3D { + positionRange: vec4f, + radianceIntensity: vec4f, + directionOuter: vec4f, + innerShadow: vec4f, +} +struct LocalLighting3DUniform { + lightCounts: vec4f, + additionalDirections: array, + additionalRadiance: array, + localLights: array, +} +struct LocalShadow3DUniform { + matrices: array, + atlas: vec4f, +} +struct LocalFogVolumes3DUniform { + ambientCount: vec4f, + volumes: array, +} +@group(0) @binding(25) var lighting: LocalLighting3DUniform; +@group(0) @binding(28) var localShadow: LocalShadow3DUniform; +@group(0) @binding(26) var localShadowImage: texture_2d; +@group(0) @binding(5) var localFog: LocalFogVolumes3DUniform; + +@vertex +fn volumetric_fog_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +// Beer-Lambert scattering over the lit part of a constant-density segment. +// Refinement handles discontinuous atlas silhouettes before the planar-gap interpolation. +fn litSegmentWeight(origin: vec3f, direction: vec3f, viewRayZ: f32, startTravel: f32, stepLength: f32, + startGap: f32, endGap: f32, opticalDepth: f32, wholeSegment: f32) -> f32 { + if startGap >= 0.0 && endGap >= 0.0 { return wholeSegment; } + if startGap < 0.0 && endGap < 0.0 { return 0.0; } + var lower = 0.0; + var upper = 1.0; + var lowerGap = startGap; + var upperGap = endGap; + for (var refine = 0; refine < 5; refine++) { + let middle = (lower + upper) * 0.5; + let travel = startTravel + middle * stepLength; + let gap = sunShadowGap(origin + direction * travel, travel * viewRayZ); + if (gap >= 0.0) == (lowerGap >= 0.0) { + lower = middle; + lowerGap = gap; + } else { + upper = middle; + upperGap = gap; + } + } + let crossing = mix(lower, upper, clamp(lowerGap / (lowerGap - upperGap), 0.0, 1.0)); + if startGap >= 0.0 { return 1.0 - exp(-opticalDepth * crossing); } + return exp(-opticalDepth * crossing) * (1.0 - exp(-opticalDepth * (1.0 - crossing))); +} + +fn localVisibility(light: LocalLight3D, world: vec3f) -> f32 { + let slot = i32(light.innerShadow.w); + if slot < 0 || slot >= 4 { return 1.0; } + let ray = world - light.positionRange.xyz; + let a = abs(ray); + var face = 0; + if light.directionOuter.w < 0.0 { + if a.x >= a.y && a.x >= a.z { face = select(1, 0, ray.x >= 0.0); } + else if a.y >= a.z { face = select(3, 2, ray.y >= 0.0); } + else { face = select(5, 4, ray.z >= 0.0); } + } + let clip = localShadow.matrices[slot * 6 + face] * vec4f(world, 1.0); + if clip.w <= 0.0 { return 1.0; } + let ndc = clip.xyz / clip.w; + if ndc.z < 0.0 || ndc.z > 1.0 || any(abs(ndc.xy) > vec2f(1.0)) { return 1.0; } + let origin = vec2f(f32(face), f32(slot)) * localShadow.atlas.z; + var uv = (origin + (ndc.xy * vec2f(0.5, -0.5) + 0.5) * localShadow.atlas.z) * localShadow.atlas.xy; + uv = clamp(uv, (origin + 0.5) * localShadow.atlas.xy, (origin + localShadow.atlas.z - 0.5) * localShadow.atlas.xy); + let stored = loadNearestShadow(localShadowImage, uv); + return select(0.0, 1.0, ndc.z - light.innerShadow.y <= stored); +} + +fn localRadiance(world: vec3f, worldDirection: vec3f) -> vec3f { + var result = vec3f(0.0); + let count = min(i32(lighting.lightCounts.y), i32(fog.march.z)); + for (var i = 0; i < 8; i++) { + if i >= count { break; } + let light = lighting.localLights[i]; + let delta = light.positionRange.xyz - world; + let distance = length(delta); + let range = light.positionRange.w; + if distance >= range || range <= 0.0 { continue; } + let direction = delta / max(distance, 0.0001); + let cutoff = max(0.0, 1.0 - pow(distance / range, 4.0)); + var attenuation = cutoff * cutoff / max(distance * distance, 0.04); + if light.directionOuter.w >= 0.0 { + attenuation *= smoothstep(light.directionOuter.w, light.innerShadow.x, dot(normalize(light.directionOuter.xyz), -direction)); + } + if attenuation <= 0.0 { continue; } + result += light.radianceIntensity.rgb * max(light.radianceIntensity.w, 0.0) * attenuation + * henyeyGreenstein(dot(worldDirection, direction), fog.sunDirection.w) * localVisibility(light, world); + } + return result * fog.sunRadiance.w; +} + +@fragment +fn volumetric_fog_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let positionMetallic = textureSampleLevel(positionImage, nearestSampler, input.uv, 0.0); + let ndc = vec2f(input.uv.x * 2.0 - 1.0, 1.0 - input.uv.y * 2.0); + let viewFar = fog.inverseProjection * vec4f(ndc, 1.0, 1.0); + let viewRay = normalize(viewFar.xyz / viewFar.w); + let hasSurface = length(positionMetallic.xyz) > 0.0001; + let surfaceDistance = select(fog.march.y, length(positionMetallic.xyz), hasSurface); + let rangeEnd = min(surfaceDistance, fog.march.y); + let rangeStart = min(fog.medium.w, rangeEnd); + let steps = i32(fog.march.x); + let stepLength = (rangeEnd - rangeStart) / f32(steps); + if stepLength <= 0.0 { return vec4f(0.0); } + let worldDirection = normalize((fog.inverseView * vec4f(viewRay, 0.0)).xyz); + let origin = fog.inverseView[3].xyz; + let phase = henyeyGreenstein(dot(worldDirection, fog.sunDirection.xyz), fog.sunDirection.w); + let boundedMedium = localFog.ambientCount.w > 0.0; + var previousSunGap = 0.0; + if boundedMedium { + previousSunGap = sunShadowGap(origin + worldDirection * rangeStart, rangeStart * viewRay.z); + } + var transmittance = 1.0; + var scattered = vec3f(0.0); + var scatteredSun = 0.0; + var localOpticalDepth = 0.0; + for (var index = 0; index < 64; index++) { + if index >= steps { break; } + let travel = rangeStart + (f32(index) + 0.5) * stepLength; + let world = origin + worldDirection * travel; + let density = fog.medium.x * exp(clamp(-fog.medium.y * (world.y - fog.medium.z), -60.0, 60.0)); + var scatteringDensity = vec3f(density); + var localDensity = 0.0; + for (var v = 0; v < 8; v++) { + if v >= i32(localFog.ambientCount.w) { break; } + let lo = localFog.volumes[v * 3]; + let hi = localFog.volumes[v * 3 + 1]; + let edge = min(world - lo.xyz, hi.xyz - world); + let fade = clamp(min(edge.x, min(edge.y, edge.z)) / hi.w, 0.0, 1.0); + let d = lo.w * fade; + localDensity += d; + scatteringDensity += d * localFog.volumes[v * 3 + 2].rgb; + } + let total = density + localDensity; + let opticalDepth = total * stepLength; + let segment = 1.0 - exp(-opticalDepth); + var sunVisible = 0.0; + if boundedMedium { + // Reuse one new endpoint lookup as the next segment's start. + let endTravel = rangeStart + f32(index + 1) * stepLength; + let nextSunGap = sunShadowGap(origin + worldDirection * endTravel, endTravel * viewRay.z); + let litWeight = litSegmentWeight(origin, worldDirection, viewRay.z, + rangeStart + f32(index) * stepLength, stepLength, previousSunGap, nextSunGap, opticalDepth, segment); + previousSunGap = nextSunGap; + let source = segment * (localRadiance(world, worldDirection) * scatteringDensity + + localDensity * localFog.ambientCount.rgb) + + litWeight * fog.sunRadiance.rgb * phase * scatteringDensity; + scattered += transmittance * source / max(total, 0.000001); + } else { + // Preserve the established global height-fog integrator and references. + sunVisible = sunVisibility(world, travel * viewRay.z); + let sun = fog.sunRadiance.rgb * (phase * sunVisible); + scattered += transmittance * segment * (localRadiance(world, worldDirection) * scatteringDensity + + sun * (scatteringDensity - vec3f(density)) + localDensity * localFog.ambientCount.rgb) / max(total, 0.000001); + } + transmittance *= exp(-total * stepLength); + if !boundedMedium { scatteredSun += density * stepLength * transmittance * sunVisible; } + localOpticalDepth += localDensity * stepLength; + } + return vec4f(scattered + fog.sunRadiance.rgb * (scatteredSun * phase), min(1.0 - exp(-localOpticalDepth), fog.march.w)); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/mesh_morph.wgsl b/Sources/AdaCorePipelines/Assets/Shaders/mesh_morph.wgsl new file mode 100644 index 000000000..d46893d21 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/mesh_morph.wgsl @@ -0,0 +1,23 @@ +struct MorphUniform { + counts: vec4f, + flags: vec4f, + current: array, + previous: array, +} + +@group(0) @binding(21) var morph: MorphUniform; +@group(0) @binding(40) var morphDeltas: texture_2d; + +const MORPH_TEXTURE_WIDTH: u32 = 2048u; + +fn morphPosition(position: vec3f, vertexIndex: u32, previous: bool) -> vec3f { + var result = position; + let count = u32(select(morph.counts.x, morph.counts.y, previous)); + for (var index = 0u; index < min(8u, count); index++) { + let weightedTarget = select(morph.current[index], morph.previous[index], previous); + let texel = (u32(weightedTarget.x) * u32(morph.counts.z) + vertexIndex) * u32(morph.counts.w); + let coordinates = vec2i(i32(texel % MORPH_TEXTURE_WIDTH), i32(texel / MORPH_TEXTURE_WIDTH)); + result += textureLoad(morphDeltas, coordinates, 0).xyz * weightedTarget.y; + } + return result; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/mesh_visibility.wgsl b/Sources/AdaCorePipelines/Assets/Shaders/mesh_visibility.wgsl new file mode 100644 index 000000000..2dcbae2b9 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/mesh_visibility.wgsl @@ -0,0 +1,41 @@ +struct SurfaceUniform { + emissiveFactor: vec4f, + surfaceProperties: vec4f, + surfaceFlags: vec4f, + uvSets: vec4f, + emission: vec4f, + parallax: vec4f, + textureTransforms: array, + dielectric: vec4f, +} + +@group(0) @binding(4) var baseColorImage: texture_2d; +@group(0) @binding(7) var baseColorSampler: sampler; +@group(0) @binding(15) var surface: SurfaceUniform; + +fn meshOrientation(model: mat4x4f) -> f32 { + let basis = mat3x3f(model[0].xyz, model[1].xyz, model[2].xyz); + return select(1.0, -1.0, determinant(basis) < 0.0); +} + +fn isVisibleFace(frontFacing: bool, orientation: f32) -> bool { + return surface.surfaceFlags.x >= 0.5 || select(frontFacing, !frontFacing, orientation < 0.0); +} + +fn isVisibleFade(pixel: vec2f, fade: f32) -> bool { + if fade <= 0.0 { + return true; + } + let at = vec2u(floor(pixel)) % vec2u(4u); + let values = array(0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); + return (values[at.y * 4u + at.x] + 0.5) / 16.0 >= fade; +} + +// The base color may have mip levels and the alpha test runs after per-pixel clipping. +@diagnostic(off, derivative_uniformity) +fn baseColorAlpha(uv0: vec2f, uv1: vec2f) -> f32 { + let uv = select(uv0, uv1, surface.uvSets.x > 0.5); + let transform = surface.textureTransforms[0]; + let at = vec2f(dot(transform.xy, uv), dot(transform.zw, uv)) + surface.textureTransforms[1].xy; + return textureSample(baseColorImage, baseColorSampler, at).a; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/meshlet_map_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/meshlet_map_3d.wesl new file mode 100644 index 000000000..eb48c484d --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/meshlet_map_3d.wesl @@ -0,0 +1,74 @@ +import ada_engine::view::{view, projectPosition}; +import package::mesh_visibility::meshOrientation; + +struct MapInput { + @location(0) positionAndID: vec4f, + @location(5) model0: vec4f, + @location(6) model1: vec4f, + @location(7) model2: vec4f, + @location(8) model3: vec4f, +} + +struct MapVertex { + @builtin(position) position: vec4f, + @location(0) @interpolate(flat) color: vec3f, + @location(1) @interpolate(flat) orientation: f32, + @location(2) viewPosition: vec3f, +} + +struct MapOutput { + @location(0) color: vec4f, + @location(1) normal: vec4f, + @location(2) position: vec4f, + @location(3) indirect: vec4f, + @if(MAP_RESPONSE) @location(4) response: vec4f, + @if(MAP_SURFACE && MAP_RESPONSE) @location(5) surface: vec4f, + @if(MAP_SURFACE && !MAP_RESPONSE) @location(4) surface: vec4f, +} + +struct MapFlags { + value: vec4f, +} + +@group(0) @binding(54) var flags: MapFlags; + +@vertex +fn meshlet_map_vertex(input: MapInput) -> MapVertex { + let model = mat4x4f(input.model0, input.model1, input.model2, input.model3); + let world = model * vec4f(input.positionAndID.xyz, 1.0); + let encodedID = u32(input.positionAndID.w); + let level = encodedID >> 20u; + var id = (encodedID & 1048575u) + 1u; + id ^= id >> 16u; + id *= 2146121005u; + id ^= id >> 15u; + id *= 2221713035u; + id ^= id >> 16u; + let channels = vec3u(id & 255u, (id >> 8u) & 255u, (id >> 16u) & 255u); + var color = vec3f(0.2) + 0.8 * vec3f(channels) / 255.0; + if level > 0u { + let palette = array( + vec3f(0.2, 0.85, 1.0), vec3f(0.95, 0.45, 0.1), vec3f(0.9, 0.2, 0.7), + vec3f(0.5, 0.35, 1.0), vec3f(1.0, 0.85, 0.15), vec3f(0.3, 1.0, 0.4), + ); + color = mix(color, palette[(level - 1u) % 6u], 0.7); + } + return MapVertex(projectPosition(world), color, meshOrientation(model), (view.viewMatrix * world).xyz); +} + +@fragment +fn meshlet_map_fragment(input: MapVertex, @builtin(front_facing) frontFacing: bool) -> MapOutput { + let normal = normalize(cross(dpdx(input.viewPosition), dpdy(input.viewPosition))); + let front = select(frontFacing, !frontFacing, input.orientation < 0.0); + if flags.value.x < 0.5 && !front { + discard; + } + var output: MapOutput; + output.color = vec4f(input.color, 1.0); + output.normal = vec4f(normal, 1.0); + output.position = vec4f(input.viewPosition, 0.0); + output.indirect = vec4f(0.0); + @if(MAP_RESPONSE) output.response = vec4f(0.0); + @if(MAP_SURFACE) output.surface = vec4f(0.0); + return output; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.wesl new file mode 100644 index 000000000..8eeeceed9 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/motion_3d.wesl @@ -0,0 +1,120 @@ +import ada_engine::view::projectPosition; +import package::mesh_visibility::{surface, meshOrientation, isVisibleFace, isVisibleFade, baseColorAlpha}; +@if(ADA_MORPH) import package::mesh_morph::morphPosition; + +// Shader Graph materials (ADA_SHADER_GRAPH) receive the generated graph source at this marker line. +// ADA_GRAPH_SOURCE_MARKER + +struct MotionInput { + @location(0) position: vec3f, + @location(1) previousModel0: vec4f, + @location(2) uv0: vec2f, + @location(3) vertexColor: vec4f, + @location(4) previousModel1: vec4f, + @location(5) model0: vec4f, + @location(6) model1: vec4f, + @location(7) model2: vec4f, + @location(8) model3: vec4f, + @location(9) color: vec4f, + @location(10) previousModel2: vec4f, + @location(11) previousModel3: vec4f, + @location(12) flags: vec4f, + @location(13) joints: vec4f, + @location(14) weights: vec4f, + @location(15) uv1: vec2f, + @if(ADA_MORPH) @builtin(vertex_index) vertexIndex: u32, +} + +struct MotionVertex { + @builtin(position) position: vec4f, + @location(0) currentClip: vec4f, + @location(1) previousClip: vec4f, + @location(2) uv0: vec2f, + @location(3) uv1: vec2f, + @location(4) colorFlags: vec4f, + @location(5) fade: f32, + @location(6) @interpolate(flat) orientation: f32, +} + +struct MotionOutput { + @location(0) motion: vec4f, + @location(1) reactive: vec4f, +} + +struct TemporalUniform { + currentViewProjection: mat4x4f, + previousViewProjection: mat4x4f, + inverseJitteredProjection: mat4x4f, + currentToPreviousView: mat4x4f, + parameters: vec4f, +} + +struct SkinningUniform { + matrices: array, +} + +@group(0) @binding(14) var skinning: SkinningUniform; +@group(0) @binding(23) var temporal: TemporalUniform; +@group(0) @binding(24) var previousSkinning: SkinningUniform; + +@vertex +fn motion_vertex(input: MotionInput) -> MotionVertex { + let identity = mat4x4f(vec4f(1, 0, 0, 0), vec4f(0, 1, 0, 0), vec4f(0, 0, 1, 0), vec4f(0, 0, 0, 1)); + var skin = identity; + var previousSkin = identity; + if input.joints.x >= 0.0 { + let joints = vec4i(input.joints) + i32(max(0.0, -input.flags.y)); + let weights = input.weights; + skin = skinning.matrices[joints.x] * weights.x + skinning.matrices[joints.y] * weights.y + + skinning.matrices[joints.z] * weights.z + skinning.matrices[joints.w] * weights.w; + previousSkin = previousSkinning.matrices[joints.x] * weights.x + previousSkinning.matrices[joints.y] * weights.y + + previousSkinning.matrices[joints.z] * weights.z + previousSkinning.matrices[joints.w] * weights.w; + } + var localPosition = input.position; + var previousLocalPosition = input.position; + @if(ADA_MORPH) localPosition = morphPosition(input.position, input.vertexIndex, false); + @if(ADA_MORPH) previousLocalPosition = morphPosition(input.position, input.vertexIndex, true); + let model = mat4x4f(input.model0, input.model1, input.model2, input.model3) * skin; + let previousModel = mat4x4f(input.previousModel0, input.previousModel1, input.previousModel2, input.previousModel3) * previousSkin; + let world = model * vec4f(localPosition, 1.0); + let previousWorld = previousModel * vec4f(previousLocalPosition, 1.0); + let currentClip = temporal.currentViewProjection * world; + var previousClip = currentClip; + if temporal.parameters.z <= 0.5 { + previousClip = temporal.previousViewProjection * previousWorld; + } + let colorFlags = vec4f(input.color.a * input.vertexColor.a, 0.0, max(0.0, input.flags.y), 0.0); + return MotionVertex(projectPosition(world), currentClip, previousClip, input.uv0, input.uv1, + colorFlags, input.flags.w, meshOrientation(model)); +} + +@fragment +fn motion_fragment(input: MotionVertex, @builtin(front_facing) frontFacing: bool) -> MotionOutput { + if !isVisibleFace(frontFacing, input.orientation) || !isVisibleFade(input.position.xy, input.fade) { + discard; + } + let mode = surface.surfaceProperties.w; + var alpha = input.colorFlags.x; + @if(ADA_SHADER_GRAPH) { + if mode > 0.5 { + let graph = ada_graph_evaluate(input.uv0); + alpha *= graph.color.a * graph.alpha; + } + } + @if(!ADA_SHADER_GRAPH) { + alpha *= baseColorAlpha(input.uv0, input.uv1); + } + if mode > 0.5 && mode < 1.5 && alpha < surface.surfaceProperties.z { + discard; + } + let valid = input.currentClip.w > 0.00001 && input.previousClip.w > 0.00001; + var velocity = vec2f(0.0); + var previousDepth = 1.0; + if valid { + velocity = (input.previousClip.xy / input.previousClip.w - input.currentClip.xy / input.currentClip.w) * vec2f(0.5, -0.5); + previousDepth = input.previousClip.z / input.previousClip.w; + } + let reactive = select(0.0, clamp(alpha * 2.0, 0.0, 1.0), mode > 1.5); + let forceReactive = input.colorFlags.z > 0.0 || !valid || input.fade > 0.0; + return MotionOutput(vec4f(velocity, previousDepth, select(0.0, 1.0, valid)), vec4f(max(reactive, select(0.0, 1.0, forceReactive)))); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/particles_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/particles_3d.wesl new file mode 100644 index 000000000..801c455b7 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/particles_3d.wesl @@ -0,0 +1,213 @@ +import ada_engine::view::view; +import ada_engine::shadow_pcf::adaNearestShadowDepth; + +// WESL port of particles_3d.glsl. Features: PARTICLE_INDIRECT adds the indirect coverage target (location 1) and +// PARTICLE_REFLECTION_RESPONSE adds the reflection-response target (location 2). + +struct SmokeLight { + positionRange: vec4f, + radianceIntensity: vec4f, + directionOuter: vec4f, + innerShadow: vec4f, +} + +struct ParticleLighting { + u_AmbientCount: vec4f, + u_SunDirectionIntensity: vec4f, + u_SunRadiance: vec4f, + u_SmokeLights: array, + u_ParticleInverseView: mat4x4f, +} + +struct ParticleShadowMatrices { + u_ParticleShadowMatrices: array, + u_ParticleAtlas: vec4f, +} + +@group(0) @binding(0) var u_Position: texture_2d; +@group(0) @binding(1) var u_Sampler: sampler; +@group(0) @binding(4) var lighting: ParticleLighting; +@group(0) @binding(5) var shadowMatrices: ParticleShadowMatrices; +@group(0) @binding(6) var u_ParticleShadows: texture_2d; + +struct ParticleVertex { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) color: vec4f, + @location(2) clip: vec4f, + @location(3) particle: vec3f, + @location(4) @interpolate(flat) style: f32, + @location(5) viewPosition: vec3f, + @location(6) @interpolate(flat) centerSize: vec4f, +} + +@vertex +fn particles_vertex( + @builtin(vertex_index) vertexIndex: u32, + @location(0) centerSize: vec4f, + @location(1) color: vec4f, + @location(2) parameters: vec4f +) -> ParticleVertex { + var corners = array( + vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0), + vec2f(-1.0, -1.0), vec2f(1.0, 1.0), vec2f(-1.0, 1.0) + ); + let corner = corners[vertexIndex]; + let c = cos(parameters.x); + let s = sin(parameters.x); + let shaped = corner * vec2f(parameters.z, 1.0); + let rotated = vec2f(c * shaped.x - s * shaped.y, s * shaped.x + c * shaped.y); + let position = centerSize.xyz + vec3f(rotated * centerSize.w * 0.5, 0.0); + let clip = view.projection * vec4f(position, 1.0); + return ParticleVertex( + clip, corner, color, clip, vec3f(centerSize.z, parameters.y, parameters.x), parameters.w, position, centerSize + ); +} + +struct ParticleOutput { + @location(0) color: vec4f, + @if(PARTICLE_INDIRECT) @location(1) indirect: vec4f, + @if(PARTICLE_REFLECTION_RESPONSE) @location(2) reflectionResponse: vec4f, +} + +fn smokePointFace(ray: vec3f) -> i32 { + let a = abs(ray); + if a.x >= a.y && a.x >= a.z { + return select(1, 0, ray.x >= 0.0); + } + if a.y >= a.z { + return select(3, 2, ray.y >= 0.0); + } + return select(5, 4, ray.z >= 0.0); +} + +fn smokeShadow(light: SmokeLight, position: vec3f) -> f32 { + let slot = i32(light.innerShadow.w); + if slot < 0 || slot >= 4 { + return 1.0; + } + let inverseView = lighting.u_ParticleInverseView; + let worldRay = mat3x3f(inverseView[0].xyz, inverseView[1].xyz, inverseView[2].xyz) * (position - light.positionRange.xyz); + var face = 0; + if light.directionOuter.w < 0.0 { + face = smokePointFace(worldRay); + } + let worldPosition = (inverseView * vec4f(position, 1.0)).xyz; + let clip = shadowMatrices.u_ParticleShadowMatrices[slot * 6 + face] * vec4f(worldPosition, 1.0); + if clip.w <= 0.0 { + return 1.0; + } + let ndc = clip.xyz / clip.w; + if ndc.z < 0.0 || ndc.z > 1.0 || any(abs(ndc.xy) > vec2f(1.0)) { + return 1.0; + } + let atlas = shadowMatrices.u_ParticleAtlas; + let origin = vec2f(f32(face), f32(slot)) * atlas.z; + let lo = (origin + vec2f(0.5)) * atlas.xy; + let hi = (origin + vec2f(atlas.z - 0.5)) * atlas.xy; + let uv = (origin + (ndc.xy * vec2f(0.5, -0.5) + vec2f(0.5)) * atlas.z) * atlas.xy; + let shadowSize = vec2i(textureDimensions(u_ParticleShadows)); + var visibility = 0.0; + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + let offset = (vec2f(f32(x), f32(y)) - vec2f(0.5)) * atlas.xy; + let depth = adaNearestShadowDepth(u_ParticleShadows, shadowSize, clamp(uv + offset, lo, hi)); + visibility += select(0.0, 1.0, ndc.z - light.innerShadow.y <= depth); + } + } + return visibility * 0.25; +} + +// Three fixed density layers approximate a small cloud. Its shape changes continuously +// with the particle's phase; no texture fetches, per-puff materials or simulation entities. +fn smokeDensity(uv: vec2f, chord: f32, phase: f32) -> f32 { + var density = 0.0; + for (var layer = 0; layer < 3; layer++) { + let p = vec3f(uv, (f32(layer) - 1.0) * chord * 0.66); + let warped = p + 0.14 * sin(p.yzx * 5.1 + vec3f(phase, 2.1, 4.3)); + let body = max(0.0, 1.0 - dot(warped, warped)); + let lobe = 0.62 + 0.38 * sin(warped.x * 7.0 + phase) * sin(warped.y * 6.0 - phase) * cos(warped.z * 6.0); + density += body * body * lobe; + } + return density * chord; +} + +fn smokeLighting(normal: vec3f, position: vec3f) -> vec3f { + var result = lighting.u_AmbientCount.rgb; + let sun = lighting.u_SunDirectionIntensity.xyz; + result += max(lighting.u_SunRadiance.rgb, vec3f(0.0)) * max(0.0, lighting.u_SunDirectionIntensity.w) * 0.12 + * (0.5 + 0.5 * dot(normal, sun)); + for (var i = 0; i < 8; i++) { + if i >= i32(lighting.u_AmbientCount.w) { + break; + } + let light = lighting.u_SmokeLights[i]; + let delta = light.positionRange.xyz - position; + let distance = length(delta); + let range = light.positionRange.w; + if distance >= range || range <= 0.0 { + continue; + } + let direction = delta / max(distance, 0.0001); + let ratio = distance / range; + let cutoff = max(0.0, 1.0 - ratio * ratio * ratio * ratio); + var attenuation = cutoff * cutoff / max(distance * distance, 0.04); + if light.directionOuter.w >= 0.0 { + attenuation *= smoothstep(light.directionOuter.w, light.innerShadow.x, dot(normalize(light.directionOuter.xyz), -direction)); + } + if attenuation <= 0.0 { + continue; + } + let wrap = 0.4 + 0.6 * max(0.0, dot(normal, direction)); + let forwardScatter = pow(max(0.0, dot(-direction, normalize(-position))), 4.0); + result += max(light.radianceIntensity.rgb, vec3f(0.0)) * max(0.0, light.radianceIntensity.w) * attenuation + * 0.08 * (wrap + forwardScatter) * smokeShadow(light, position); + } + return result; +} + +// Reflections are composed later. Their opaque contribution must obey the same +// transmittance as sceneColor or it would shine through a dense cloud. +fn particleOutput(color: vec4f, alpha: f32) -> ParticleOutput { + var result: ParticleOutput; + result.color = color; + @if(PARTICLE_INDIRECT) result.indirect = vec4f(0.0, 0.0, 0.0, alpha); + @if(PARTICLE_REFLECTION_RESPONSE) result.reflectionResponse = vec4f(0.0, 0.0, 0.0, alpha); + return result; +} + +@fragment +fn particles_fragment(input: ParticleVertex) -> ParticleOutput { + let radius = dot(input.uv, input.uv); + if radius >= 1.0 { + discard; + } + let screenUV = input.clip.xy / input.clip.w * vec2f(0.5, -0.5) + vec2f(0.5); + let scene = textureSampleLevel(u_Position, u_Sampler, screenUV, 0.0).xyz; + let sceneDepth = select(scene.z, 100000.0, length(scene) < 0.0001); + let intersection = clamp((sceneDepth - input.particle.x) / input.particle.y, 0.0, 1.0); + let nearFade = smoothstep(0.05, 0.3, input.particle.x); + if input.style > 2.5 { + let chord = sqrt(max(0.0, 1.0 - radius)); + let density = smokeDensity(input.uv, chord, input.particle.z); + let alpha = (1.0 - exp(-density * input.color.a * 2.0)) * intersection * nearFade; + if alpha < 0.0001 { + discard; + } + // A rounded view-space normal gives each density lobe a lit and an extinct side. + let normal = normalize(vec3f( + (input.viewPosition.xy - input.centerSize.xy) / max(input.centerSize.w * 0.5, 0.0001), + -chord + )); + let position = input.viewPosition + vec3f(0.0, 0.0, -chord * input.centerSize.w * 0.25); + return particleOutput(vec4f(input.color.rgb * smokeLighting(normal, position), alpha), alpha); + } + // Smooth procedural lobes keep smoke soft and remove the need for a texture asset. + let detail = select( + 0.78 + 0.22 * sin(input.uv.x * 6.0 + input.particle.z) * cos(input.uv.y * 5.0 - input.particle.z), + 1.0, + input.style > 0.5 + ); + let alpha = input.color.a * (1.0 - radius) * (1.0 - radius) * detail * intersection * nearFade; + return particleOutput(vec4f(input.color.rgb, alpha), alpha); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.wesl new file mode 100644 index 000000000..5888d2b98 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.wesl @@ -0,0 +1,665 @@ +// WESL port of path_tracing_3d.glsl. One fullscreen pass selected by `pu.u_Frame.y`: +// 0 trace, 1 temporal, 2 guide, 3 wavelet denoise, 4 reconstruct, 5 Ray Reconstruction guides. + +struct PathVertex { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn path_vertex(@builtin(vertex_index) vertexIndex: u32) -> PathVertex { + let uv = vec2f(f32(vertexIndex >> 1u), f32(vertexIndex & 1u)) * 2.0; + return PathVertex(vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.0, 1.0), uv); +} + +struct PathUniform { + u_InverseProjection: mat4x4f, + u_InverseView: mat4x4f, + u_Counts: vec4f, // nodes, lights, spp, surface interactions + u_Trace: vec4f, // distance, bias, radiance clamp, history frames + u_Frame: vec4f, // frame seed, pass (0 trace/1 filter/2 reconstruct), atlas rows, linear environment texture + u_Zenith: vec4f, + u_Horizon: vec4f, + u_Ground: vec4f, + u_Sky: vec4f, // environment texture enabled, intensity, rotation, procedural enabled + u_Hybrid: vec4f, // x: first hit from the raster G-buffer + u_PreviousViewProjection: mat4x4f, // unjittered, previous frame of this view + u_PreviousView: mat4x4f, + u_Denoise: vec4f, // x: wavelet step, y: minimum history weight, z: trace writes demodulated light, w: final remodulates +} + +@group(0) @binding(0) var u_Nodes: texture_2d; +@group(0) @binding(1) var u_Triangles: texture_2d; +@group(0) @binding(2) var u_Materials: texture_2d; +@group(0) @binding(3) var u_Atlas: texture_2d; +@group(0) @binding(4) var pu: PathUniform; +@group(0) @binding(5) var u_Lights: texture_2d; +@group(0) @binding(6) var u_History: texture_2d; +@group(0) @binding(7) var u_Environment: texture_2d; +@group(0) @binding(8) var u_Nearest: sampler; +@group(0) @binding(9) var u_Linear: sampler; +@group(0) @binding(10) var u_Normal: texture_2d; +@group(0) @binding(11) var u_Position: texture_2d; +@group(0) @binding(12) var u_Albedo: texture_2d; // G-buffer base colour (path-tracing surface outputs) +@group(0) @binding(13) var u_Emission: texture_2d; // G-buffer emission +@group(0) @binding(14) var u_InputB: texture_2d; // pass-specific second input +@group(0) @binding(15) var u_InputC: texture_2d; // pass-specific third input + +const PI: f32 = 3.141592653589793; + +var randomState: u32; + +fn randomValue() -> f32 { + randomState = randomState * 747796405u + 2891336453u; + let word = ((randomState >> ((randomState >> 28u) + 4u)) ^ randomState) * 277803737u; + return f32((word >> 22u) ^ word) / 4294967296.0; +} + +fn texelCoordinate(index: i32) -> vec2i { + return vec2i(index % 2048, index / 2048); +} +fn tri(index: i32) -> vec4f { return textureLoad(u_Triangles, texelCoordinate(index), 0); } +fn node(index: i32) -> vec4f { return textureLoad(u_Nodes, texelCoordinate(index), 0); } +fn material(index: i32) -> vec4f { return textureLoad(u_Materials, texelCoordinate(index), 0); } +fn light(index: i32) -> vec4f { return textureLoad(u_Lights, texelCoordinate(index), 0); } + +fn atlas(tile: f32, uv: vec2f) -> vec4f { + if tile < 0.0 { + return vec4f(1.0); + } + // Half texel inset prevents adjacent materials bleeding into a repeated tile. + let size = vec2f(textureDimensions(u_Atlas, 0)); + let tileSize = size.x / 16.0; + let pixel = clamp(fract(uv) * tileSize, vec2f(0.5), vec2f(tileSize - 0.5)); + let origin = vec2f(tile - 16.0 * floor(tile / 16.0), floor(tile / 16.0)) * tileSize; + return textureSampleLevel(u_Atlas, u_Linear, (origin + pixel) / size, 0.0); +} + +fn srgb(color: vec3f) -> vec3f { + return select(color / 12.92, pow((color + vec3f(0.055)) / 1.055, vec3f(2.4)), color > vec3f(0.04045)); +} + +fn sky(directionIn: vec3f) -> vec3f { + var direction = directionIn; + let angle = pu.u_Sky.z; + let rotated = mat2x2f(cos(angle), -sin(angle), sin(angle), cos(angle)) * vec2f(direction.x, direction.z); + direction = vec3f(rotated.x, direction.y, rotated.y); + if pu.u_Sky.x > 0.5 { + let uv = vec2f(atan2(direction.z, direction.x) / (2.0 * PI) + 0.5, asin(clamp(direction.y, -1.0, 1.0)) / PI + 0.5); + let value = max(textureSampleLevel(u_Environment, u_Linear, uv, 0.0).rgb, vec3f(0.0)); + return select(srgb(value), value, pu.u_Frame.w > 0.5) * pu.u_Sky.y; + } + if pu.u_Sky.w < 0.5 { + return vec3f(0.0); + } + let pole = select(pu.u_Ground.rgb, pu.u_Zenith.rgb, direction.y >= 0.0); + return srgb(mix(pu.u_Horizon.rgb, pole, pow(abs(direction.y), 0.6))) * pu.u_Sky.y; +} + +fn basis(normal: vec3f) -> mat3x3f { + let t = normalize(select(cross(vec3f(0.0, 1.0, 0.0), normal), cross(vec3f(0.0, 0.0, 1.0), normal), abs(normal.z) < 0.9)); + return mat3x3f(t, cross(normal, t), normal); +} + +fn boxHit(origin: vec3f, direction: vec3f, lo: vec3f, hi: vec3f, distance: f32) -> bool { + // Parallel slabs need an explicit containment test; 0 * infinity is undefined. + var nearT = 0.0; + var farT = distance; + for (var axis = 0; axis < 3; axis++) { + if abs(direction[axis]) < 1e-9 { + if origin[axis] < lo[axis] || origin[axis] > hi[axis] { + return false; + } + } else { + let a = (lo[axis] - origin[axis]) / direction[axis]; + let b = (hi[axis] - origin[axis]) / direction[axis]; + nearT = max(nearT, min(a, b)); + farT = min(farT, max(a, b)); + if farT < nearT { + return false; + } + } + } + return true; +} + +// Returns the closest actual world triangle, with optional cast-shadow filtering. +fn intersectScene(origin: vec3f, direction: vec3f, distance: ptr, bary: ptr, shadowRay: bool) -> i32 { + var hit = -1; + *bary = vec3f(0.0); + var cursor = 0; + while cursor < i32(pu.u_Counts.x) { + let lo = node(cursor * 3); + let hi = node(cursor * 3 + 1); + let info = node(cursor * 3 + 2); + if !boxHit(origin, direction, lo.xyz, hi.xyz, *distance) { + cursor = i32(lo.w); + continue; + } + let count = i32(info.x); + for (var i = 0; i < 4; i++) { + if i >= count { + break; + } + let index = (i32(hi.w) + i) * 12; + let a = tri(index); + let b = tri(index + 1); + let c = tri(index + 2); + if shadowRay && (i32(b.w) & 1) == 0 { + continue; + } + let e1 = b.xyz - a.xyz; + let e2 = c.xyz - a.xyz; + let p = cross(direction, e2); + let determinant = dot(e1, p); + if abs(determinant) < 1e-10 { + continue; + } + let m = i32(a.w) * 5; + if material(m + 2).w < 0.5 && determinant < 0.0 { + continue; + } + let t = origin - a.xyz; + let u = dot(t, p) / determinant; + let q = cross(t, e1); + let v = dot(direction, q) / determinant; + let travel = dot(e2, q) / determinant; + if u < 0.0 || v < 0.0 || u + v > 1.0 || travel <= pu.u_Trace.y || travel >= *distance { + continue; + } + let weights = vec3f(1.0 - u - v, u, v); + let uv = tri(index + 6).xy * weights.x + tri(index + 7).xy * weights.y + tri(index + 8).xy * weights.z; + let alpha = material(m).w * atlas(material(m + 3).x, uv).a; + let mode = i32(material(m + 4).x); + if mode == 1 && alpha < material(m + 2).z { + continue; + } + if mode == 2 && randomValue() > alpha { + continue; + } + hit = index; + *bary = weights; + *distance = travel; + if shadowRay { + return hit; + } + } + cursor += 1; + } + return hit; +} + +struct Surface { + position: vec3f, + normal: vec3f, + geometric: vec3f, + color: vec3f, + emission: vec3f, + roughness: f32, + metal: f32, + receives: bool, +} + +fn surfaceAt(hit: i32, bary: vec3f, origin: vec3f, direction: vec3f, distance: f32) -> Surface { + var s: Surface; + let m = i32(tri(hit).w) * 5; + let factor = material(m); + let parameters = material(m + 2); + let tiles = material(m + 3); + let uv = tri(hit + 6) * bary.x + tri(hit + 7) * bary.y + tri(hit + 8) * bary.z; + let other = tri(hit + 9) * bary.x + tri(hit + 10) * bary.y + tri(hit + 11) * bary.z; + s.position = origin + direction * distance; + s.geometric = normalize(cross(tri(hit + 1).xyz - tri(hit).xyz, tri(hit + 2).xyz - tri(hit).xyz)); + let n = tri(hit + 3).xyz * bary.x + tri(hit + 4).xyz * bary.y + tri(hit + 5).xyz * bary.z; + s.normal = select(s.geometric, normalize(n), dot(n, n) > 0.00001); + if dot(s.geometric, direction) > 0.0 { + s.geometric = -s.geometric; + } + if dot(s.normal, direction) > 0.0 { + s.normal = -s.normal; + } + if tiles.w >= 0.0 { + let du = tri(hit + 10).zw - tri(hit + 9).zw; + let dv = tri(hit + 11).zw - tri(hit + 9).zw; + let det = du.x * dv.y - du.y * dv.x; + if abs(det) > 0.000001 { + let e1 = tri(hit + 1).xyz - tri(hit).xyz; + let e2 = tri(hit + 2).xyz - tri(hit).xyz; + var t = (e1 * dv.y - e2 * du.y) / det; + t = normalize(t - s.normal * dot(s.normal, t)); + let b = cross(s.normal, t) * sign(det); + var mapped = atlas(tiles.w, other.zw).xyz * 2.0 - vec3f(1.0); + mapped = vec3f(mapped.xy * material(m + 4).y, mapped.z); + let mappedNormal = normalize(mat3x3f(t, b, s.normal) * mapped); + if dot(mappedNormal, s.geometric) > 0.0 { + s.normal = mappedNormal; + } + } + } + s.color = clamp(factor.rgb * select(vec3f(1.0), srgb(atlas(tiles.x, uv.xy).rgb), tiles.x >= 0.0), vec3f(0.0), vec3f(1.0)); + let mr = atlas(tiles.y, uv.zw); + s.metal = clamp(parameters.x * mr.b, 0.0, 1.0); + s.roughness = clamp(parameters.y * mr.g, 0.04, 1.0); + s.emission = max(material(m + 1).rgb, vec3f(0.0)) * select(vec3f(1.0), srgb(atlas(tiles.z, other.xy).rgb), tiles.z >= 0.0); + s.receives = (i32(tri(hit + 1).w) & 2) != 0; + return s; +} + +fn fresnel(f0: vec3f, cosine: f32) -> vec3f { + return f0 + (vec3f(1.0) - f0) * pow(1.0 - clamp(cosine, 0.0, 1.0), 5.0); +} + +fn brdf(s: Surface, view: vec3f, direction: vec3f) -> vec3f { + let nv = max(dot(s.normal, view), 0.0001); + let nl = max(dot(s.normal, direction), 0.0); + if nl <= 0.0 { + return vec3f(0.0); + } + let halfVector = normalize(view + direction); + let nh = max(dot(s.normal, halfVector), 0.0); + let vh = max(dot(view, halfVector), 0.0); + let a = s.roughness * s.roughness; + let a2 = a * a; + let denominator = nh * nh * (a2 - 1.0) + 1.0; + let d = a2 / max(PI * denominator * denominator, 1e-10); + let gv = 2.0 * nv / (nv + sqrt(a2 + (1.0 - a2) * nv * nv)); + let gl = 2.0 * nl / (nl + sqrt(a2 + (1.0 - a2) * nl * nl)); + let f = fresnel(mix(vec3f(0.04), s.color, s.metal), vh); + return ((vec3f(1.0) - f) * (1.0 - s.metal) * s.color / PI + f * d * gv * gl / max(4.0 * nv * nl, 0.0001)) * nl; +} + +fn sampleLight(s: Surface, view: vec3f, selectedIndex: i32) -> vec3f { + let selected = selectedIndex * 4; + let a = light(selected); + let b = light(selected + 1); + let c = light(selected + 2); + let d = light(selected + 3); + var direction: vec3f; + var distance = pu.u_Trace.x; + var attenuation = 1.0; + if a.w < 0.5 { + direction = normalize(a.xyz); + if d.z > 0.0 { + let r = sqrt(randomValue()) * d.z; + let phi = 2.0 * PI * randomValue(); + direction = normalize(direction + basis(direction) * vec3f(r * cos(phi), r * sin(phi), 0.0)); + } + } else { + let delta = a.xyz - s.position; + distance = length(delta); + if distance >= b.w || distance < 0.00001 { + return vec3f(0.0); + } + direction = delta / distance; + let cutoff = max(0.0, 1.0 - pow(distance / b.w, 4.0)); + attenuation = cutoff * cutoff / max(distance * distance, 0.01); + if a.w > 1.5 { + attenuation *= smoothstep(d.y, d.x, dot(-direction, normalize(c.xyz))); + } + } + if dot(s.normal, direction) <= 0.0 || dot(s.geometric, direction) <= 0.0 { + return vec3f(0.0); + } + if s.receives && c.w > 0.5 { + var maximum = max(pu.u_Trace.y, distance - pu.u_Trace.y * 2.0); + var bary: vec3f; + if intersectScene(s.position + s.geometric * pu.u_Trace.y * 2.0, direction, &maximum, &bary, true) >= 0 { + return vec3f(0.0); + } + } + return brdf(s, view, direction) * max(b.rgb, vec3f(0.0)) * attenuation; +} + +fn directLight(s: Surface, view: vec3f) -> vec3f { + // Deterministic enumeration removes light-selection noise from the small authored game light set. + var result = vec3f(0.0); + for (var index = 0; index < i32(pu.u_Counts.y); index++) { + result += sampleLight(s, view, index); + } + return result; +} + +// Samples the next direction from a diffuse/GGX mixture and updates the throughput; false ends the path. +fn scatter(s: Surface, view: vec3f, throughput: ptr, nextDirection: ptr) -> bool { + let f0 = mix(vec3f(0.04), s.color, s.metal); + let chance = clamp(max(f0.r, max(f0.g, f0.b)), 0.1, 0.9); + if randomValue() < chance { + let u = randomValue(); + let phi = 2.0 * PI * randomValue(); + let a = s.roughness * s.roughness; + let cosine = sqrt((1.0 - u) / max(1.0 + (a * a - 1.0) * u, 0.000001)); + let halfVector = normalize(basis(s.normal) * vec3f(sqrt(max(0.0, 1.0 - cosine * cosine)) * vec2f(cos(phi), sin(phi)), cosine)); + *nextDirection = reflect(-view, halfVector); + } else { + let r = sqrt(randomValue()); + let phi = 2.0 * PI * randomValue(); + *nextDirection = normalize(basis(s.normal) * vec3f(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - r * r)))); + } + let nl = dot(s.normal, *nextDirection); + if nl <= 0.0 || dot(s.geometric, *nextDirection) <= 0.0 { + return false; + } + let h = normalize(view + *nextDirection); + let nh = max(dot(s.normal, h), 0.0); + let a = s.roughness * s.roughness; + let a2 = a * a; + let denominator = nh * nh * (a2 - 1.0) + 1.0; + let d = a2 / max(PI * denominator * denominator, 1e-10); + let pdf = (1.0 - chance) * nl / PI + chance * d * nh / max(4.0 * abs(dot(view, h)), 0.00001); + *throughput = *throughput * (brdf(s, view, *nextDirection) / max(pdf, 1e-8)); + return true; +} + +// Traces from `origin`; `firstBounce` counts interactions already shaded by the caller. +fn path(originIn: vec3f, directionIn: vec3f, firstBounce: i32) -> vec3f { + var origin = originIn; + var direction = directionIn; + var throughput = vec3f(1.0); + var radiance = vec3f(0.0); + for (var bounce = firstBounce; bounce < 8; bounce++) { + if bounce >= i32(pu.u_Counts.w) { + break; + } + var distance = pu.u_Trace.x; + var bary: vec3f; + let hit = intersectScene(origin, direction, &distance, &bary, false); + if hit < 0 { + radiance += throughput * sky(direction); + break; + } + let s = surfaceAt(hit, bary, origin, direction, distance); + radiance += throughput * (s.emission + directLight(s, -direction)); + var nextDirection: vec3f; + if !scatter(s, -direction, &throughput, &nextDirection) { + break; + } + if bounce >= 2 { + let survive = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.05, 0.95); + if randomValue() > survive { + break; + } + throughput = throughput / survive; + } + origin = s.position + s.geometric * pu.u_Trace.y * 2.0; + direction = nextDirection; + } + return radiance; +} + +struct GBufferHit { + valid: bool, + surface: Surface, + view: vec3f, +} + +// The raster G-buffer's surface at `uv`: full model detail, independent of the traced LOD geometry. +fn gbufferSurface(uv: vec2f) -> GBufferHit { + var result: GBufferHit; + result.valid = false; + if pu.u_Hybrid.x < 0.5 { + return result; + } + let normalRoughness = textureSampleLevel(u_Normal, u_Nearest, uv, 0.0); + if dot(normalRoughness.xyz, normalRoughness.xyz) < 0.01 { + return result; + } + let positionMetallic = textureSampleLevel(u_Position, u_Nearest, uv, 0.0); + let inverseView = pu.u_InverseView; + var s: Surface; + s.position = (inverseView * vec4f(positionMetallic.xyz, 1.0)).xyz; + s.normal = normalize(mat3x3f(inverseView[0].xyz, inverseView[1].xyz, inverseView[2].xyz) * normalRoughness.xyz); + let view = normalize(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 = textureSampleLevel(u_Albedo, u_Nearest, uv, 0.0).rgb; + s.emission = max(textureSampleLevel(u_Emission, u_Nearest, uv, 0.0).rgb, vec3f(0.0)); + s.roughness = clamp(normalRoughness.w, 0.04, 1.0); + s.metal = clamp(positionMetallic.w, 0.0, 1.0); + s.receives = true; + result.valid = true; + result.surface = s; + result.view = view; + return result; +} + +fn hasSurface(uv: vec2f) -> bool { + let n = textureSampleLevel(u_Normal, u_Nearest, uv, 0.0).xyz; + return dot(n, n) > 0.01; +} + +// Base colour that divides lighting before filtering, so texture detail is never blurred. 1 off surfaces. +fn demodulation(uv: vec2f) -> vec3f { + if pu.u_Hybrid.x < 0.5 || !hasSurface(uv) { + return vec3f(1.0); + } + return max(textureSampleLevel(u_Albedo, u_Nearest, uv, 0.0).rgb, vec3f(0.03)); +} + +fn emissionAt(uv: vec2f) -> vec3f { + if pu.u_Hybrid.x < 0.5 || !hasSurface(uv) { + return vec3f(0.0); + } + return max(textureSampleLevel(u_Emission, u_Nearest, uv, 0.0).rgb, vec3f(0.0)); +} + +fn luminance(c: vec3f) -> f32 { + return dot(c, vec3f(0.2126, 0.7152, 0.0722)); +} + +fn viewRotation() -> mat3x3f { + let inverseView = pu.u_InverseView; + return mat3x3f(inverseView[0].xyz, inverseView[1].xyz, inverseView[2].xyz); +} + +// 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). +fn temporalPass(vUV: vec2f) -> vec4f { + let current = textureSampleLevel(u_History, u_Nearest, vUV, 0.0).rgb; + let normal = textureSampleLevel(u_Normal, u_Nearest, vUV, 0.0); + if dot(normal.xyz, normal.xyz) < 0.01 || pu.u_Trace.w < 0.5 { + return vec4f(current, 1.0); + } + let viewPosition = textureSampleLevel(u_Position, u_Nearest, vUV, 0.0).xyz; + let world = (pu.u_InverseView * vec4f(viewPosition, 1.0)).xyz; + let worldNormal = normalize(viewRotation() * normal.xyz); + let clip = pu.u_PreviousViewProjection * vec4f(world, 1.0); + var history = vec4f(0.0); + if clip.w > 0.0001 { + let uv = clip.xy / clip.w * vec2f(0.5, -0.5) + vec2f(0.5); + if all(uv >= vec2f(0.0)) && all(uv <= vec2f(1.0)) { + let guide = textureSampleLevel(u_InputC, u_Nearest, uv, 0.0); + let expected = length((pu.u_PreviousView * vec4f(world, 1.0)).xyz); + if dot(guide.xyz, worldNormal) > 0.9 && abs(guide.w - expected) < 0.05 * expected + 0.02 { + history = textureSampleLevel(u_InputB, u_Linear, uv, 0.0); + } + } + } + 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. + let texel = vec2f(1.0) / vec2f(textureDimensions(u_History, 0)); + var mean = vec3f(0.0); + var square = vec3f(0.0); + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + let value = textureSampleLevel(u_History, u_Nearest, clamp(vUV + vec2f(f32(x), f32(y)) * texel, vec2f(0.0), vec2f(1.0)), 0.0).rgb; + mean += value; + square += value * value; + } + } + mean = mean / 9.0; + let sigma = sqrt(max(square / 9.0 - mean * mean, vec3f(0.0))); + history = vec4f(clamp(history.rgb, mean - 2.0 * sigma, mean + 2.0 * sigma), history.a); + } + let frames = min(history.a + 1.0, 1024.0); + return vec4f(mix(history.rgb, current, max(1.0 / frames, pu.u_Denoise.y)), frames); +} + +// Pass 2: this frame's guide for the next frame's reprojection. +fn guidePass(vUV: vec2f) -> vec4f { + let normal = textureSampleLevel(u_Normal, u_Nearest, vUV, 0.0); + if dot(normal.xyz, normal.xyz) < 0.01 { + return vec4f(0.0); + } + let viewPosition = textureSampleLevel(u_Position, u_Nearest, vUV, 0.0).xyz; + return vec4f(normalize(viewRotation() * 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. +fn waveletPass(vUV: vec2f) -> vec4f { + let center = textureSampleLevel(u_History, u_Nearest, vUV, 0.0); + let normal = textureSampleLevel(u_Normal, u_Nearest, vUV, 0.0); + if dot(normal.xyz, normal.xyz) < 0.01 { + return center; + } + let texel = vec2f(1.0) / vec2f(textureDimensions(u_History, 0)); + var mean = 0.0; + var square = 0.0; + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + let l = luminance(textureSampleLevel(u_History, u_Nearest, clamp(vUV + vec2f(f32(x), f32(y)) * texel, vec2f(0.0), vec2f(1.0)), 0.0).rgb); + mean += l; + square += l * l; + } + } + mean = mean / 9.0; + let sigma = 4.0 * sqrt(max(square / 9.0 - mean * mean, 0.0)) + 0.0001; + let depth = length(textureSampleLevel(u_Position, u_Nearest, vUV, 0.0).xyz); + let centerLuminance = luminance(center.rgb); + var kernel = array(0.375, 0.25, 0.0625); + var sum = vec3f(0.0); + var weights = 0.0; + for (var y = -2; y <= 2; y++) { + for (var x = -2; x <= 2; x++) { + let uv = vUV + vec2f(f32(x), f32(y)) * texel * pu.u_Denoise.x; + if any(uv < vec2f(0.0)) || any(uv > vec2f(1.0)) { + continue; + } + let otherNormal = textureSampleLevel(u_Normal, u_Nearest, uv, 0.0); + if dot(otherNormal.xyz, otherNormal.xyz) < 0.01 { + continue; + } + let value = textureSampleLevel(u_History, u_Nearest, uv, 0.0).rgb; + let otherDepth = length(textureSampleLevel(u_Position, u_Nearest, uv, 0.0).xyz); + var weight = kernel[abs(x)] * kernel[abs(y)]; + weight = weight * pow(max(dot(normalize(normal.xyz), normalize(otherNormal.xyz)), 0.0), 128.0); + weight = weight * exp(-abs(depth - otherDepth) / (0.01 * depth * pu.u_Denoise.x + 0.001)); + weight = weight * exp(-abs(centerLuminance - luminance(value)) / sigma); + sum += value * weight; + weights += weight; + } + } + return vec4f(select(center.rgb, sum / weights, weights > 0.00001), center.a); +} + +// Pass 4: geometry-aware reconstruction at output resolution, then remodulation with the full-resolution surface. +fn finalPass(vUV: vec2f) -> vec4f { + let n = textureSampleLevel(u_Normal, u_Nearest, vUV, 0.0).xyz; + let p = textureSampleLevel(u_Position, u_Nearest, vUV, 0.0).xyz; + let size = vec2f(textureDimensions(u_History, 0)); + let center = floor(vUV * size - vec2f(0.5)); + let fraction = fract(vUV * size - vec2f(0.5)); + var sum = vec3f(0.0); + var weightSum = 0.0; + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + let uv = clamp((center + vec2f(f32(x), f32(y)) + vec2f(0.5)) / size, vec2f(0.0), vec2f(1.0)); + let otherN = textureSampleLevel(u_Normal, u_Nearest, uv, 0.0).xyz; + let otherP = textureSampleLevel(u_Position, u_Nearest, uv, 0.0).xyz; + var weight = select(fraction.x, 1.0 - fraction.x, x == 0) * select(fraction.y, 1.0 - fraction.y, y == 0); + let surface = dot(n, n) > 0.01; + let otherSurface = dot(otherN, otherN) > 0.01; + if surface != otherSurface { + weight = 0.0; + } + if surface && otherSurface { + weight = 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 += textureSampleLevel(u_History, u_Nearest, uv, 0.0).rgb * weight; + weightSum += weight; + } + } + var result = select(textureSampleLevel(u_History, u_Nearest, vUV, 0.0).rgb, sum / weightSum, weightSum > 0.00001); + if pu.u_Denoise.w > 0.5 { + result = result * demodulation(vUV) + emissionAt(vUV); + } + return vec4f(result, 1.0); +} + +// Pass 5: Ray Reconstruction guides; u_Denoise.x selects diffuse albedo (0), specular albedo (1) or world normal +// with roughness (2). +fn rayReconstructionGuidePass(vUV: vec2f) -> vec4f { + let normal = textureSampleLevel(u_Normal, u_Nearest, vUV, 0.0); + if dot(normal.xyz, normal.xyz) < 0.01 { + return vec4f(0.0); + } + if pu.u_Denoise.x > 1.5 { + return vec4f(normalize(viewRotation() * normal.xyz), normal.w); + } + let base = textureSampleLevel(u_Albedo, u_Nearest, vUV, 0.0).rgb; + let metal = textureSampleLevel(u_Position, u_Nearest, vUV, 0.0).w; + return vec4f(select(mix(vec3f(0.04), base, metal), base * (1.0 - metal), pu.u_Denoise.x < 0.5), 1.0); +} + +@fragment +fn path_fragment(input: PathVertex) -> @location(0) vec4f { + let vUV = input.uv; + // Pass 0 traces; 1 temporal, 2 guide and 3 wavelet denoise; 4 reconstructs the output; 5 writes RR guides. + if pu.u_Frame.y > 4.5 { return rayReconstructionGuidePass(vUV); } + if pu.u_Frame.y > 3.5 { return finalPass(vUV); } + if pu.u_Frame.y > 2.5 { return waveletPass(vUV); } + if pu.u_Frame.y > 1.5 { return guidePass(vUV); } + if pu.u_Frame.y > 0.5 { return temporalPass(vUV); } + let pixel = vec2u(input.position.xy); + randomState = pixel.x * 1973u + pixel.y * 9277u + u32(pu.u_Frame.x) * 26699u + 911u; + var color = vec3f(0.0); + let first = gbufferSurface(vUV); + let hybrid = first.valid; + if pu.u_Hybrid.x > 0.5 && !hybrid { + // The raster pass drew nothing here: the sky. Never trace it, so path-tracing-only proxies stay invisible. + let ndc = vUV * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0); + let nearPoint = pu.u_InverseProjection * vec4f(ndc, 0.0, 1.0); + let farPoint = pu.u_InverseProjection * vec4f(ndc, 1.0, 1.0); + let direction = normalize(viewRotation() * (farPoint.xyz / farPoint.w - nearPoint.xyz / nearPoint.w)); + return vec4f(clamp(sky(direction), vec3f(0.0), vec3f(pu.u_Trace.z)), 1.0); + } + for (var sampleIndex = 0; sampleIndex < 8; sampleIndex++) { + if sampleIndex >= i32(pu.u_Counts.z) { + break; + } + if hybrid { + var throughput = vec3f(1.0); + var nextDirection: vec3f; + var lighting = directLight(first.surface, first.view); + if i32(pu.u_Counts.w) > 1 && scatter(first.surface, first.view, &throughput, &nextDirection) { + lighting += throughput * path(first.surface.position + first.surface.geometric * pu.u_Trace.y * 2.0, nextDirection, 1); + } + // Clamp only the light: emission is added after, so demodulation removes it exactly. + color += clamp(lighting, vec3f(0.0), vec3f(pu.u_Trace.z)) + first.surface.emission; + continue; + } + let size = vec2f(textureDimensions(u_History, 0)); + let uv = vUV + (vec2f(randomValue(), randomValue()) - vec2f(0.5)) / size; + let ndc = uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0); + let nearPoint = pu.u_InverseProjection * vec4f(ndc, 0.0, 1.0); + let farPoint = pu.u_InverseProjection * vec4f(ndc, 1.0, 1.0); + let viewOrigin = nearPoint.xyz / nearPoint.w; + let direction = normalize(viewRotation() * (farPoint.xyz / farPoint.w - viewOrigin)); + let origin = (pu.u_InverseView * vec4f(viewOrigin, 1.0)).xyz; + color += clamp(path(origin, direction, 0), vec3f(0.0), vec3f(pu.u_Trace.z)); + } + color = color / pu.u_Counts.z; + if pu.u_Denoise.z > 0.5 { + // Denoisers filter light without the surface's own colour and emission; the final pass restores both. + return vec4f(max(color - emissionAt(vUV), vec3f(0.0)) / demodulation(vUV), 1.0); + } + // Plain accumulation of a still view (reference mode). + if pu.u_Trace.w > 0.0 { + color = mix(color, textureSampleLevel(u_History, u_Nearest, vUV, 0.0).rgb, pu.u_Trace.w / (pu.u_Trace.w + 1.0)); + } + return vec4f(color, 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_composite_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_composite_3d.wesl new file mode 100644 index 000000000..3af0a1a7f --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/path_tracing_composite_3d.wesl @@ -0,0 +1,24 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; +import package::environment_composite::{ + sceneColor, normalRoughness, viewPositionMetallic, linearSampler, gBufferSampler, environment, presentColor, applyFog, +}; + +@vertex +fn ssr_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +@fragment +fn path_composite_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let uv = input.uv; + let color = textureSampleLevel(sceneColor, linearSampler, uv, 0.0).rgb; + var position = textureSampleLevel(viewPositionMetallic, gBufferSampler, uv, 0.0).xyz; + let normal = textureSampleLevel(normalRoughness, gBufferSampler, uv, 0.0).xyz; + let isSky = dot(normal, normal) < 0.01 || dot(position, position) < 0.000001; + if isSky { + let farPoint = environment.inverseProjection * vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 1.0, 1.0); + position = normalize(farPoint.xyz / farPoint.w) * 1000.0; + } + let fogged = applyFog(color, position, select(1.0, environment.fogRange.x, isSky), isSky, uv); + return vec4f(presentColor(fogged, uv, input.position.xy), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/portable_temporal_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/portable_temporal_3d.wesl new file mode 100644 index 000000000..c9793f4c1 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/portable_temporal_3d.wesl @@ -0,0 +1,82 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct TemporalConstants { + parameters: vec4f, +} + +struct TemporalOutput { + @location(0) color: vec4f, + @location(1) history: vec4f, + @location(2) depth: f32, +} + +@group(0) @binding(0) var colorImage: texture_2d; +@group(0) @binding(1) var depthImage: texture_depth_2d; +@group(0) @binding(2) var motionImage: texture_2d; +@group(0) @binding(3) var reactiveImage: texture_2d; +@group(0) @binding(4) var historyImage: texture_2d; +@group(0) @binding(5) var historyDepth: texture_2d; +@group(0) @binding(6) var linearSampler: sampler; +@group(0) @binding(7) var nearestSampler: sampler; +@group(0) @binding(8) var temporal: TemporalConstants; + +@vertex +fn fullscreen_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn toYCoCg(color: vec3f) -> vec3f { + return vec3f(dot(color, vec3f(0.25, 0.5, 0.25)), color.r - color.b, color.g - (color.r + color.b) * 0.5); +} + +fn toRGB(color: vec3f) -> vec3f { + return vec3f(color.x + color.y * 0.5 - color.z * 0.5, color.x + color.z * 0.5, color.x - color.y * 0.5 - color.z * 0.5); +} + +@fragment +fn temporal_fragment(input: FullscreenVertex) -> TemporalOutput { + let size = vec2i(textureDimensions(colorImage, 0)); + let texel = 1.0 / vec2f(size); + let uv = clamp(input.uv + temporal.parameters.xy, texel * 0.5, vec2f(1.0) - texel * 0.5); + let center = clamp(vec2i(uv * vec2f(size)), vec2i(0), size - 1); + var selected = center; + var closest = 1.0; + var low = vec3f(1e20); + var high = vec3f(-1e20); + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + let pixel = clamp(center + vec2i(x, y), vec2i(0), size - 1); + let color = toYCoCg(textureLoad(colorImage, pixel, 0).rgb); + low = min(low, color); + high = max(high, color); + let depth = textureLoad(depthImage, pixel, 0); + if depth < closest { + closest = depth; + selected = pixel; + } + } + } + let motion = textureLoad(motionImage, selected, 0); + let previousUV = input.uv + motion.xy; + let currentDepth = textureLoad(depthImage, center, 0); + let historySize = vec2i(textureDimensions(historyImage, 0)); + let historyPixel = clamp(vec2i(previousUV * vec2f(historySize)), vec2i(0), historySize - 1); + let oldDepth = textureLoad(historyDepth, historyPixel, 0).r; + let inside = all(previousUV > vec2f(0.0)) && all(previousUV < vec2f(1.0)); + let threshold = max(0.00015, abs(dpdx(currentDepth)) + abs(dpdy(currentDepth))); + let valid = temporal.parameters.z < 0.5 && inside && motion.w > 0.5 && abs(oldDepth - motion.z) < threshold; + let current = textureSampleLevel(colorImage, linearSampler, uv, 0.0).rgb; + if !valid { + return TemporalOutput(vec4f(current, 1.0), vec4f(current, 1.0), currentDepth); + } + let history = clamp(toYCoCg(textureSampleLevel(historyImage, linearSampler, previousUV, 0.0).rgb), low, high); + let reactive = textureSampleLevel(reactiveImage, nearestSampler, uv, 0.0).r; + let speed = length(motion.xy * vec2f(size)); + let weight = mix(0.92, 0.55, clamp(speed / 32.0, 0.0, 1.0)) * (1.0 - reactive); + // Luminance compression limits the influence of bright emitters on history. + let previous = max(toRGB(history), vec3f(0.0)); + let a = weight / (1.0 + max(max(previous.r, previous.g), previous.b)); + let b = (1.0 - weight) / (1.0 + max(max(current.r, current.g), current.b)); + let result = vec4f((previous * a + current * b) / max(a + b, 0.000001), 1.0); + return TemporalOutput(result, result, currentDepth); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/quad.wesl b/Sources/AdaCorePipelines/Assets/Shaders/quad.wesl new file mode 100644 index 000000000..be35efe4d --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/quad.wesl @@ -0,0 +1,25 @@ +import ada_engine::view::projectPosition; + +struct QuadVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +} + +@group(0) @binding(0) var image: texture_2d; +@group(0) @binding(1) var imageSampler: sampler; + +@vertex +fn quad_vertex(@location(0) position: vec4f, @location(1) color: vec4f, @location(2) uv: vec2f) -> QuadVertex { + return QuadVertex(projectPosition(position), color, uv); +} + +@fragment +fn quad_fragment(input: QuadVertex) -> @location(0) vec4f { + let color = textureSample(image, imageSampler, input.uv) * input.color; + // Discard transparent fragments so they cannot write depth. + if color.a == 0.0 { + discard; + } + return color; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/screen_space_gi_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/screen_space_gi_3d.wesl new file mode 100644 index 000000000..90354f0aa --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/screen_space_gi_3d.wesl @@ -0,0 +1,119 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct ScreenSpaceGI3DUniform { + projection: mat4x4f, + trace: vec4f, + quality: vec4f, +} +@group(0) @binding(0) var positionImage: texture_2d; +@group(0) @binding(1) var normalImage: texture_2d; +@group(0) @binding(2) var colorImage: texture_2d; +@group(0) @binding(3) var surfaceImage: texture_2d; +@group(0) @binding(4) var gi: ScreenSpaceGI3DUniform; +@group(0) @binding(5) var indirectImage: texture_2d; +@group(0) @binding(6) var rawImage: texture_2d; +@group(0) @binding(7) var nearestSampler: sampler; +const PI = 3.14159265359; + +@vertex +fn fullscreen_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn positionAt(uv: vec2f) -> vec3f { return textureSampleLevel(positionImage, nearestSampler, uv, 0.0).xyz; } +fn normalAt(uv: vec2f) -> vec3f { return textureSampleLevel(normalImage, nearestSampler, uv, 0.0).xyz; } + +// Replaces GLSL's out parameter without discarding the projection validity bit. +struct Projection { + uv: vec2f, + valid: bool, +} +fn project(position: vec3f) -> Projection { + let clip = gi.projection * vec4f(position, 1.0); + if clip.w <= 0.00001 { return Projection(vec2f(0.0), false); } + let uv = clip.xy / clip.w * vec2f(0.5, -0.5) + 0.5; + return Projection(uv, all(uv > vec2f(0.001)) && all(uv < vec2f(0.999))); +} + +fn traceRay(origin: vec3f, direction: vec3f, receiver: vec3f) -> vec3f { + var previousDistance = gi.trace.z; + for (var step = 1; step <= 32; step++) { + if step > i32(gi.trace.w) { break; } + let fraction = f32(step) / gi.trace.w; + let distance = gi.trace.z + fraction * fraction * gi.trace.x; + let point = origin + direction * distance; + let projected = project(point); + if !projected.valid { break; } + var uv = projected.uv; + var surface = positionAt(uv); + if length(surface) < 0.0001 { previousDistance = distance; continue; } + let difference = point.z - surface.z; + if difference >= 0.0 && difference < gi.trace.y + distance - previousDistance { + // Refine the first crossing rather than shining through a wall. + var low = previousDistance; + var high = distance; + for (var iteration = 0; iteration < 4; iteration++) { + let middle = (low + high) * 0.5; + let query = origin + direction * middle; + let candidate = project(query); + if !candidate.valid { break; } + let visible = positionAt(candidate.uv); + if length(visible) > 0.0001 && query.z >= visible.z { high = middle; uv = candidate.uv; } + else { low = middle; } + } + surface = positionAt(uv); + if length(surface - receiver) < gi.trace.z * 2.0 { return vec3f(0.0); } + let hitNormal = normalAt(uv); + if length(hitNormal) < 0.1 || dot(normalize(hitNormal), -direction) <= 0.0 { return vec3f(0.0); } + // Only rebounce direct light and emission, not the baked ambient. + let lit = textureSampleLevel(colorImage, nearestSampler, uv, 0.0).rgb; + let indirect = textureSampleLevel(indirectImage, nearestSampler, uv, 0.0).rgb; + return clamp(lit - indirect, vec3f(0.0), vec3f(gi.quality.z)); + } + previousDistance = distance; + } + return vec3f(0.0); +} + +@fragment +fn screen_space_gi_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let point = positionAt(input.uv); + var normal = normalAt(input.uv); + if length(point) < 0.0001 || length(normal) < 0.1 { return vec4f(0.0); } + normal = normalize(normal); + let surface = textureSampleLevel(surfaceImage, nearestSampler, input.uv, 0.0).rgb; + if gi.quality.w > 0.5 { + let size = vec2f(textureDimensions(rawImage, 0)); + var sum = vec3f(0.0); + var weights = 0.0; + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + let uv = clamp(input.uv + vec2f(f32(x), f32(y)) / size, vec2f(0.0), vec2f(1.0)); + let otherNormal = normalAt(uv); + let otherPosition = positionAt(uv); + if length(otherNormal) < 0.1 { continue; } + let otherSurface = textureSampleLevel(surfaceImage, nearestSampler, uv, 0.0).rgb; + let weight = pow(max(dot(normal, normalize(otherNormal)), 0.0), 16.0) + * exp(-abs(point.z - otherPosition.z) / max(0.04, gi.trace.z * 4.0)) + * exp(-length(surface - otherSurface) * 8.0); + sum += textureSampleLevel(rawImage, nearestSampler, uv, 0.0).rgb * weight; + weights += weight; + } + } + return vec4f(sum / max(weights, 0.00001), 1.0); + } + let tangent = normalize(select(cross(normal, vec3f(0, 1, 0)), cross(normal, vec3f(0, 0, 1)), abs(normal.z) < 0.9)); + let bitangent = cross(normal, tangent); + // Stable fixed-pixel rotation; camera jitter is accumulated by TAA/DLSS. + let rotation = fract(sin(dot(floor(input.uv * vec2f(textureDimensions(positionImage, 0))), vec2f(12.9898, 78.233))) * 43758.5453); + var irradiance = vec3f(0.0); + for (var ray = 0; ray < 16; ray++) { + if ray >= i32(gi.quality.x) { break; } + let u = (f32(ray) + 0.5) / gi.quality.x; + let phi = 2.0 * PI * fract(f32(ray) * 0.61803398875 + rotation); + let direction = tangent * (sqrt(u) * cos(phi)) + bitangent * (sqrt(u) * sin(phi)) + normal * sqrt(1.0 - u); + irradiance += traceRay(point + normal * gi.trace.z, direction, point); + } + // Cosine-weighted hemisphere sampling cancels Lambert's pi. + return vec4f(surface * irradiance * (gi.quality.y / gi.quality.x), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.wesl b/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.wesl new file mode 100644 index 000000000..08f57d161 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/screen_space_reflection.wesl @@ -0,0 +1,305 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; +import package::environment_composite::{ + sceneColor, normalRoughness, viewPositionMetallic, linearSampler, gBufferSampler, environment, + srgbToLinear, presentColor, applyFog, +}; + +@group(0) @binding(5) var environmentImage: texture_2d; +@group(0) @binding(6) var radianceImage: texture_2d; +@group(0) @binding(7) var radianceSampler: sampler; +@group(0) @binding(8) var indirectImage: texture_2d; +@group(0) @binding(9) var aoImage: texture_2d; +@group(0) @binding(10) var iblSampler: sampler; +@group(0) @binding(17) var reflectionResponse: texture_2d; +@group(0) @binding(18) var prefilteredEnvironment: texture_2d; +@group(0) @binding(20) var diffuseImage: texture_2d; + +const PI: f32 = 3.14159265359; +const MINIMUM_TRACED_REFLECTIVITY: f32 = 0.004; + +@vertex +fn ssr_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn sampleAt(image: texture_2d, imageSampler: sampler, uv: vec2f) -> vec4f { + return textureSampleLevel(image, imageSampler, uv, 0.0); +} + +fn sampleDiffuseGI(uv: vec2f, position: vec3f, normal: vec3f) -> vec3f { + if environment.diffuseGI.x < 0.5 { + return vec3f(0.0); + } + let size = vec2f(textureDimensions(diffuseImage, 0)); + let pixel = uv * size - 0.5; + let base = floor(pixel); + let fraction = fract(pixel); + var sum = vec3f(0.0); + var weights = 0.0; + for (var y = 0; y <= 1; y++) { + for (var x = 0; x <= 1; x++) { + let at = clamp((base + vec2f(f32(x), f32(y)) + 0.5) / size, vec2f(0.0), vec2f(1.0)); + let otherPosition = sampleAt(viewPositionMetallic, gBufferSampler, at).xyz; + let otherNormal = sampleAt(normalRoughness, gBufferSampler, at).xyz; + if length(otherNormal) < 0.1 { + continue; + } + let bilinear = select(fraction.x, 1.0 - fraction.x, x == 0) * select(fraction.y, 1.0 - fraction.y, y == 0); + let weight = bilinear * pow(max(dot(normal, normalize(otherNormal)), 0.0), 16.0) + * exp(-abs(position.z - otherPosition.z) / max(0.04, environment.diffuseGI.y * 4.0)); + sum += sampleAt(diffuseImage, gBufferSampler, at).rgb * weight; + weights += weight; + } + } + return sum / max(weights, 0.00001); +} + +fn skyUV(direction: vec3f) -> vec2f { + return vec2f(atan2(direction.z, direction.x) / (2.0 * PI) + 0.5, asin(clamp(direction.y, -1.0, 1.0)) / PI + 0.5); +} + +fn rotateSky(direction: vec3f, angle: f32) -> vec3f { + let rotated = mat2x2f(cos(angle), -sin(angle), sin(angle), cos(angle)) * direction.xz; + return vec3f(rotated.x, direction.y, rotated.y); +} + +fn proceduralSky(direction: vec3f) -> vec3f { + let height = clamp(direction.y, -1.0, 1.0); + let hemisphere = select(environment.groundColor.rgb, environment.zenithColor.rgb, height >= 0.0); + let sky = mix(environment.horizonColor.rgb, hemisphere, pow(abs(height), 0.55)); + if environment.environmentFlags.w < 0.5 { + return sky; + } + let gridPosition = skyUV(direction) * vec2f(220.0, 110.0); + var hashInput = fract(vec3f(floor(gridPosition), environment.starfield.w) * vec3f(0.1031, 0.1030, 0.0973)); + hashInput += dot(hashInput, hashInput.yzx + 33.33); + let existence = fract((hashInput.x + hashInput.y) * hashInput.z); + let offset = vec2f(fract(existence * 17.17 + hashInput.x), fract(existence * 31.73 + hashInput.y)); + let distanceToStar = length((fract(gridPosition) - offset) / max(environment.starfield.z, 0.1)); + let threshold = mix(0.998, 0.72, environment.starfield.x); + let starExists = step(threshold, existence); + let star = (1.0 - smoothstep(0.0, 0.11, distanceToStar)) * starExists; + let glow = (1.0 - smoothstep(0.0, 0.42, distanceToStar)) * starExists * 0.22; + let starColor = mix(vec3f(1.0, 0.72, 0.46), vec3f(0.58, 0.76, 1.0), fract(existence * 47.0)); + return sky + starColor * (star + glow) * environment.starfield.y; +} + +// Preserve implicit LOD for authored sky textures, including calls from the reflective surface branch. +@diagnostic(off, derivative_uniformity) +fn sampleSky(viewDirection: vec3f) -> vec3f { + if environment.environmentFlags.x < 0.5 { + return srgbToLinear(environment.clearColor.rgb); + } + let direction = normalize((environment.inverseView * vec4f(viewDirection, 0.0)).xyz); + if environment.ibl.x > 0.5 && environment.ibl.w > 0.5 { + return textureSample(radianceImage, radianceSampler, skyUV(rotateSky(direction, environment.ibl.z))).rgb * environment.ibl.y; + } + if environment.environmentFlags.y > 0.5 { + return srgbToLinear(textureSample(environmentImage, linearSampler, skyUV(direction)).rgb) * environment.environmentFlags.z; + } + return srgbToLinear(proceduralSky(direction)) * environment.environmentFlags.z; +} + +fn edgeVisibility(uv: vec2f) -> f32 { + let edge = min(uv, vec2f(1.0) - uv); + return smoothstep(0.0, environment.reflectionQuality.z, min(edge.x, edge.y)); +} + +fn blurredScene(uv: vec2f, roughness: f32) -> vec3f { + let radius = 0.012 * roughness * roughness; + let horizontal = vec2f(radius, 0.0); + let vertical = vec2f(0.0, radius); + return sampleAt(sceneColor, linearSampler, uv).rgb * 0.4 + + sampleAt(sceneColor, linearSampler, uv + horizontal).rgb * 0.15 + + sampleAt(sceneColor, linearSampler, uv - horizontal).rgb * 0.15 + + sampleAt(sceneColor, linearSampler, uv + vertical).rgb * 0.15 + + sampleAt(sceneColor, linearSampler, uv - vertical).rgb * 0.15; +} + +struct ScreenPoint { + uv: vec2f, + visible: bool, +} + +fn projectToScreen(point: vec3f) -> ScreenPoint { + let clip = environment.projection * vec4f(point, 1.0); + if clip.w <= 0.0 { + return ScreenPoint(vec2f(0.0), false); + } + let uv = clip.xy / clip.w * vec2f(0.5, -0.5) + 0.5; + return ScreenPoint(uv, !(any(uv <= vec2f(0.0)) || any(uv >= vec2f(1.0)))); +} + +fn depthBehindSurface(point: vec3f, uv: vec2f) -> f32 { + let scenePosition = sampleAt(viewPositionMetallic, gBufferSampler, uv).xyz; + return select(-1000000.0, point.z - scenePosition.z, length(scenePosition) > 0.0001); +} + +// Use double strides for the march and four refinements after crossing a surface. +fn traceReflection(origin: vec3f, direction: vec3f) -> ScreenPoint { + let stride = environment.reflection.z * 2.0; + let thickness = environment.reflection.w; + var previous = origin + direction * thickness; + var ray = previous; + let steps = (min(i32(environment.reflectionQuality.x), 64) + 1) / 2; + for (var stepIndex = 0; stepIndex < 32; stepIndex++) { + if stepIndex >= steps { + break; + } + ray += direction * stride; + if distance(ray, origin) > environment.reflection.y { + break; + } + let projected = projectToScreen(ray); + if !projected.visible { + break; + } + let behind = depthBehindSurface(ray, projected.uv); + if behind > 0.0 && behind < stride + thickness { + var nearPoint = previous; + var farPoint = ray; + for (var refinement = 0; refinement < 4; refinement++) { + let middle = (nearPoint + farPoint) * 0.5; + let projectedMiddle = projectToScreen(middle); + if !projectedMiddle.visible { + break; + } + if depthBehindSurface(middle, projectedMiddle.uv) > 0.0 { + farPoint = middle; + } else { + nearPoint = middle; + } + } + let hit = projectToScreen(farPoint); + if hit.visible && abs(depthBehindSurface(farPoint, hit.uv)) <= thickness { + return hit; + } + } + previous = ray; + } + return ScreenPoint(vec2f(0.0), false); +} + +fn ambientOcclusion(uv: vec2f, position: vec3f, normal: vec3f) -> f32 { + if environment.quality.x < 0.5 { + return 1.0; + } + let size = vec2f(textureDimensions(aoImage, 0)); + let base = floor(uv * size - 0.5); + let fraction = fract(uv * size - 0.5); + var sum = 0.0; + var weights = 0.0; + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + let at = clamp((base + vec2f(f32(x), f32(y)) + 0.5) / size, 0.5 / size, vec2f(1.0) - 0.5 / size); + let other = sampleAt(viewPositionMetallic, gBufferSampler, at).xyz; + let otherNormal = sampleAt(normalRoughness, gBufferSampler, at).xyz; + if other.z <= 0.0 || length(otherNormal) < 0.1 { + continue; + } + let bilinear = select(fraction.x, 1.0 - fraction.x, x == 0) * select(fraction.y, 1.0 - fraction.y, y == 0); + var weight = bilinear * exp(-abs(position.z - other.z) / max(0.02, environment.quality.y * 0.1)); + weight *= pow(max(dot(normal, normalize(otherNormal)), 0.0), 8.0); + sum += sampleAt(aoImage, gBufferSampler, at).r * weight; + weights += weight; + } + } + if weights > 0.0001 { + return sum / weights; + } + return 1.0; +} + +@diagnostic(off, derivative_uniformity) +fn probeReflection(viewPosition: vec3f, viewDirection: vec3f, roughness: f32) -> vec3f { + var direction = normalize((environment.inverseView * vec4f(viewDirection, 0.0)).xyz); + let position = (environment.inverseView * vec4f(viewPosition, 1.0)).xyz; + let minimum = environment.probeMinimum.xyz; + let maximum = environment.probeMaximum.xyz; + if environment.probePosition.w > 0.5 && all(position >= minimum) && all(position <= maximum) { + let flatAxis = abs(direction) < vec3f(0.000001); + let safeDirection = select(direction, vec3f(0.000001), flatAxis); + let boundary = select(minimum, maximum, direction >= vec3f(0.0)); + let travel = select((boundary - position) / safeDirection, vec3f(1e20), flatAxis); + let projected = position + direction * min(travel.x, min(travel.y, travel.z)) - environment.probePosition.xyz; + if dot(projected, projected) > 0.000001 { + direction = normalize(projected); + } + } + let uv = skyUV(rotateSky(direction, environment.ibl.z)); + let levels = max(environment.reflectionQuality.w, 2.0); + let layer = roughness * (levels - 1.0); + let lower = floor(layer); + let upper = min(lower + 1.0, levels - 1.0); + let texels = f32(textureDimensions(prefilteredEnvironment, 0).y) / levels; + let y = clamp(uv.y, 0.5 / texels, 1.0 - 0.5 / texels); + let a = textureSample(prefilteredEnvironment, iblSampler, vec2f(uv.x, (lower + y) / levels)).rgb; + let b = textureSample(prefilteredEnvironment, iblSampler, vec2f(uv.x, (upper + y) / levels)).rgb; + return mix(a, b, layer - lower) * environment.ibl.y; +} + +fn composite(color: vec3f, position: vec3f, skyAmount: f32, isSky: bool, input: FullscreenVertex) -> vec4f { + let fogged = applyFog(color, position, skyAmount, isSky, input.uv); + return vec4f(presentColor(fogged, input.uv, input.position.xy), 1.0); +} + +@fragment +fn ssr_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let uv = input.uv; + let surface = sampleAt(normalRoughness, gBufferSampler, uv); + let positionMetallic = sampleAt(viewPositionMetallic, gBufferSampler, uv); + let position = positionMetallic.xyz; + let foreground = sampleAt(sceneColor, linearSampler, uv); + if length(surface.xyz) < 0.1 || length(position) < 0.0001 { + let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); + let viewFar = environment.inverseProjection * vec4f(ndc, 1.0, 1.0); + let viewRay = normalize(viewFar.xyz / viewFar.w); + // The HDR scene holds premultiplied transparent geometry and particles at sky pixels. + let skyWithForeground = foreground.rgb + sampleSky(viewRay) * (1.0 - clamp(foreground.a, 0.0, 1.0)); + return composite(skyWithForeground, viewRay * 1000.0, environment.fogRange.x, true, input); + } + + let normal = normalize(surface.xyz); + let ao = ambientOcclusion(uv, position, normal); + let indirect = sampleAt(indirectImage, linearSampler, uv).rgb; + var baseColor = max(foreground.rgb - indirect * (1.0 - ao), vec3f(0.0)); + baseColor += sampleDiffuseGI(uv, position, normal) * ao; + let roughness = clamp(surface.w, 0.04, 1.0); + let metallic = clamp(positionMetallic.w, 0.0, 1.0); + let viewDirection = normalize(position); + let reflectionDirection = normalize(reflect(viewDirection, normal)); + if environment.ibl.x > 0.5 { + if environment.quality.w > 0.5 && environment.reflection.x > 0.5 && environment.reflectionQuality.y > 0.0 { + let response = sampleAt(reflectionResponse, gBufferSampler, uv).rgb * ao; + if max(response.r, max(response.g, response.b)) > MINIMUM_TRACED_REFLECTIVITY { + let hit = traceReflection(position + normal * environment.reflection.w, reflectionDirection); + if hit.visible { + let weight = edgeVisibility(hit.uv) * clamp(environment.reflectionQuality.y, 0.0, 1.0); + let previous = probeReflection(position, reflectionDirection, roughness); + baseColor = max(baseColor + (blurredScene(hit.uv, roughness) - previous) * response * weight, vec3f(0.0)); + } + } + } + return composite(baseColor, position, 1.0, false, input); + } + // Baked diffuse light already supplies ambient illumination. + if environment.diffuseGI.z > 0.5 { + return composite(baseColor, position, 1.0, false, input); + } + let sky = sampleSky(reflectionDirection); + let fresnel = pow(1.0 - clamp(dot(-viewDirection, normal), 0.0, 1.0), 5.0); + var reflectivity = mix(0.04, 1.0, metallic) * mix(1.0, fresnel, 0.35); + reflectivity *= (1.0 - roughness * 0.7) * environment.reflectionQuality.y; + var reflectedColor = sky; + var visibility = 0.0; + if environment.reflection.x > 0.5 && reflectivity * ao > MINIMUM_TRACED_REFLECTIVITY { + let hit = traceReflection(position + normal * environment.reflection.w, reflectionDirection); + if hit.visible { + reflectedColor = blurredScene(hit.uv, roughness); + visibility = edgeVisibility(hit.uv); + } + } + let ambient = sampleSky(normal) * (0.035 + 0.035 * (1.0 - roughness)); + let reflectionResult = mix(sky, reflectedColor, visibility); + return composite(baseColor + (ambient + reflectionResult * reflectivity) * ao, position, 1.0, false, input); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.wesl new file mode 100644 index 000000000..e55fabe75 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/shadow_clear_3d.wesl @@ -0,0 +1,11 @@ +import package::fullscreen::fullscreenVertex; + +@vertex +fn shadow_clear_vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { + return fullscreenVertex(index).position; +} + +@fragment +fn shadow_clear_fragment() -> @location(0) vec4f { + return vec4f(1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/sky_motion_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/sky_motion_3d.wesl new file mode 100644 index 000000000..5749815af --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/sky_motion_3d.wesl @@ -0,0 +1,37 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct TemporalUniform { + currentViewProjection: mat4x4f, + previousViewProjection: mat4x4f, + inverseJitteredProjection: mat4x4f, + currentToPreviousView: mat4x4f, + parameters: vec4f, +} + +struct SkyMotion { + @location(0) motion: vec4f, + @location(1) reactive: vec4f, +} + +@group(0) @binding(23) var temporal: TemporalUniform; + +@vertex +fn fullscreen_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +@fragment +fn sky_motion_fragment(input: FullscreenVertex) -> SkyMotion { + let ndc = input.uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0); + let point = temporal.inverseJitteredProjection * vec4f(ndc, 1.0, 1.0); + // W=0 removes camera translation from the sky projection. + let direction = vec4f(point.xyz / point.w, 0.0); + let current = temporal.currentViewProjection * direction; + let previous = temporal.previousViewProjection * direction; + let valid = current.w > 0.00001 && previous.w > 0.00001; + var velocity = vec2f(0.0); + if valid && temporal.parameters.z < 0.5 { + velocity = (previous.xy / previous.w - current.xy / current.w) * vec2f(0.5, -0.5); + } + return SkyMotion(vec4f(velocity, 1.0, select(0.0, 1.0, valid)), vec4f(select(1.0, 0.0, valid))); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/ssao_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/ssao_3d.wesl new file mode 100644 index 000000000..9614c1407 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/ssao_3d.wesl @@ -0,0 +1,94 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +struct SSAOUniform { + projection: mat4x4f, + parameters: vec4f, + passParameters: vec4f, +} + +@group(0) @binding(0) var positionImage: texture_2d; +@group(0) @binding(1) var normalImage: texture_2d; +@group(0) @binding(2) var rawAO: texture_2d; +@group(0) @binding(3) var nearestSampler: sampler; +@group(0) @binding(4) var ssao: SSAOUniform; + +@vertex +fn ssao_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn sampleAt(image: texture_2d, uv: vec2f) -> vec3f { + return textureSampleLevel(image, nearestSampler, uv, 0.0).xyz; +} + +fn bilateralAO(uv: vec2f, position: vec3f, normal: vec3f) -> f32 { + let pixel = 1.0 / vec2f(textureDimensions(rawAO, 0)); + var sum = 0.0; + var weightSum = 0.0; + for (var y = -2; y <= 2; y++) { + for (var x = -2; x <= 2; x++) { + let sampleUV = clamp(uv + vec2f(f32(x), f32(y)) * pixel, pixel * 0.5, vec2f(1.0) - pixel * 0.5); + let other = sampleAt(positionImage, sampleUV); + let otherNormal = sampleAt(normalImage, sampleUV); + if other.z <= 0.0 || length(otherNormal) < 0.1 { + continue; + } + var weight = exp(-f32(x*x + y*y) / 4.0) * exp(-abs(position.z - other.z) / max(0.02, ssao.parameters.x * 0.1)); + weight *= pow(max(dot(normal, normalize(otherNormal)), 0.0), 8.0); + sum += sampleAt(rawAO, sampleUV).r * weight; + weightSum += weight; + } + } + if weightSum > 0.0001 { + return sum / weightSum; + } + return 1.0; +} + +fn rawOcclusion(screenPosition: vec2f, position: vec3f, normal: vec3f) -> f32 { + let helper = select(vec3f(1.0, 0.0, 0.0), vec3f(0.0, 1.0, 0.0), abs(normal.y) < 0.95); + let tangent = normalize(cross(helper, normal)); + let bitangent = cross(normal, tangent); + let cell = floor(screenPosition) % 4.0; + let rotation = fract(sin(dot(cell, vec2f(12.9898, 78.233))) * 43758.5453) * 6.2831853; + var occlusion = 0.0; + for (var index = 0; index < 32; index++) { + if index >= i32(ssao.parameters.w) { + break; + } + let fraction = (f32(index) + 0.5) / ssao.parameters.w; + let radial = sqrt(fraction); + let angle = f32(index) * 2.3999632 + rotation; + let direction = tangent * cos(angle) * radial + bitangent * sin(angle) * radial + normal * sqrt(1.0 - fraction); + let samplePoint = position + direction * ssao.parameters.x * mix(0.2, 1.0, fraction * fraction); + let clip = ssao.projection * vec4f(samplePoint, 1.0); + if clip.w <= 0.0 { + continue; + } + let uv = clip.xy / clip.w * vec2f(0.5, -0.5) + 0.5; + if any(uv < vec2f(0.0)) || any(uv > vec2f(1.0)) { + continue; + } + let surface = sampleAt(positionImage, uv); + if surface.z <= 0.0 || distance(surface, position) > ssao.parameters.x { + continue; + } + let range = smoothstep(0.0, 1.0, ssao.parameters.x / max(abs(position.z - surface.z), 0.001)); + occlusion += select(0.0, range, surface.z < samplePoint.z - ssao.parameters.z); + } + return clamp(1.0 - occlusion / ssao.parameters.w * ssao.parameters.y, 0.1, 1.0); +} + +@fragment +fn ssao_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let position = sampleAt(positionImage, input.uv); + let normal = sampleAt(normalImage, input.uv); + if length(normal) < 0.1 || position.z <= 0.0 { + return vec4f(1.0); + } + let direction = normalize(normal); + if ssao.passParameters.x > 0.5 { + return vec4f(vec3f(bilateralAO(input.uv, position, direction)), 1.0); + } + return vec4f(vec3f(rawOcclusion(input.position.xy, position, direction)), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/temporal_depth_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/temporal_depth_3d.wesl new file mode 100644 index 000000000..53091b5d1 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/temporal_depth_3d.wesl @@ -0,0 +1,25 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; + +@vertex +fn fullscreen_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +struct TemporalUniform { + currentViewProjection: mat4x4f, + previousViewProjection: mat4x4f, + inverseJitteredProjection: mat4x4f, + currentToPreviousView: mat4x4f, + parameters: vec4f, +} + +@group(0) @binding(0) var depthImage: texture_depth_2d; +@group(0) @binding(23) var temporal: TemporalUniform; + +@fragment +fn temporal_depth_fragment(input: FullscreenVertex) -> @builtin(frag_depth) f32 { + // Load the exact value from the unfilterable depth texture. + let size = vec2i(textureDimensions(depthImage, 0)); + let pixel = clamp(vec2i((input.uv + temporal.parameters.xy) * vec2f(size)), vec2i(0), size - vec2i(1)); + return textureLoad(depthImage, pixel, 0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/temporal_tonemap_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/temporal_tonemap_3d.wesl new file mode 100644 index 000000000..49d29a548 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/temporal_tonemap_3d.wesl @@ -0,0 +1,31 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; +import package::tonemapping::{outputTransform, ditherDisplay}; +import package::color_grading::applyLut; + +struct TemporalUniform { + currentViewProjection: mat4x4f, + previousViewProjection: mat4x4f, + inverseJitteredProjection: mat4x4f, + currentToPreviousView: mat4x4f, + parameters: vec4f, + toneMapping: vec4f, +} + +@group(0) @binding(0) var image: texture_2d; +@group(0) @binding(1) var imageSampler: sampler; +@group(0) @binding(2) var gradingLut: texture_2d; +@group(0) @binding(3) var gradingSampler: sampler; +@group(0) @binding(23) var temporal: TemporalUniform; + +@vertex +fn fullscreen_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +@fragment +fn temporal_tonemap_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let uv = input.uv + select(vec2f(0.0), temporal.parameters.xy, temporal.parameters.w > 0.5); + let color = max(textureSample(image, imageSampler, uv).rgb, vec3f(0.0)); + let display = applyLut(gradingLut, gradingSampler, outputTransform(color, temporal.toneMapping.x), temporal.toneMapping.y); + return vec4f(ditherDisplay(display, input.position.xy), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/tonemapping.wgsl b/Sources/AdaCorePipelines/Assets/Shaders/tonemapping.wgsl new file mode 100644 index 000000000..7568d30b7 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/tonemapping.wgsl @@ -0,0 +1,53 @@ +fn linearToSrgb(value: vec3f) -> vec3f { + let color = max(value, vec3f(0.0)); + return mix(color * 12.92, 1.055 * pow(color, vec3f(1.0 / 2.4)) - 0.055, step(vec3f(0.0031308), color)); +} + +fn acesFit(color: vec3f) -> vec3f { + return clamp((color * (2.51 * color + 0.03)) / (color * (2.43 * color + 0.59) + 0.14), vec3f(0.0), vec3f(1.0)); +} + +// Khronos PBR Neutral Tone Mapper (https://github.com/KhronosGroup/ToneMapping), Apache-2.0. +fn neutralToneMap(color: vec3f) -> vec3f { + let startCompression = 0.8 - 0.04; + let desaturation = 0.15; + let x = min(color.r, min(color.g, color.b)); + let offset = select(0.04, x - 6.25 * x * x, x < 0.08); + let shifted = color - offset; + let peak = max(shifted.r, max(shifted.g, shifted.b)); + if peak < startCompression { + return shifted; + } + let d = 1.0 - startCompression; + let newPeak = 1.0 - d * d / (peak + d - startCompression); + let scaled = shifted * (newPeak / peak); + let gray = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0); + return mix(scaled, vec3f(newPeak), gray); +} + +// Matches ToneMapper3D.shaderMode: ACES, PBR Neutral, or clamped linear. +fn toneMap(color: vec3f, mode: f32) -> vec3f { + if mode > 1.5 { + return clamp(color, vec3f(0.0), vec3f(1.0)); + } + if mode > 0.5 { + return clamp(neutralToneMap(max(color, vec3f(0.0))), vec3f(0.0), vec3f(1.0)); + } + return acesFit(color); +} + +fn outputTransform(color: vec3f, mode: f32) -> vec3f { + return linearToSrgb(toneMap(color, mode)); +} + +// Use a screen-stable RGB threshold and emit exact 8-bit display codes. +fn ditherDisplay(color: vec3f, pixel: vec2f) -> vec3f { + let coordinate = vec2u(floor(pixel)); + var hash = (coordinate.x * 0x1f123bb5u) ^ (coordinate.y * 0x5f356495u); + hash = (hash ^ (hash >> 16u)) * 0x7feb352du; + hash = (hash ^ (hash >> 15u)) * 0x846ca68bu; + hash ^= hash >> 16u; + let threshold = (f32(hash & 65535u) + 0.5) / 65536.0; + let codes = floor(clamp(color, vec3f(0.0), vec3f(1.0)) * 255.0 + vec3f(threshold)); + return clamp(codes, vec3f(0.0), vec3f(255.0)) / 255.0; +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.wesl b/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.wesl new file mode 100644 index 000000000..40653d284 --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_3d.wesl @@ -0,0 +1,42 @@ +import package::fullscreen::{FullscreenVertex, fullscreenVertex}; +import package::volumetric_fog_common::{fog, positionImage, nearestSampler, henyeyGreenstein, sunVisibility}; + +@vertex +fn volumetric_fog_vertex(@builtin(vertex_index) index: u32) -> FullscreenVertex { + return fullscreenVertex(index); +} + +fn interleavedGradientNoise(pixel: vec2f) -> f32 { + return fract(52.9829189 * fract(dot(pixel, vec2f(0.06711056, 0.00583715)))); +} + +@fragment +fn volumetric_fog_fragment(input: FullscreenVertex) -> @location(0) vec4f { + let positionMetallic = textureSampleLevel(positionImage, nearestSampler, input.uv, 0.0); + let ndc = vec2f(input.uv.x * 2.0 - 1.0, 1.0 - input.uv.y * 2.0); + let viewFar = fog.inverseProjection * vec4f(ndc, 1.0, 1.0); + let viewRay = normalize(viewFar.xyz / viewFar.w); + let hasSurface = length(positionMetallic.xyz) > 0.0001; + let surfaceDistance = select(fog.march.y, length(positionMetallic.xyz), hasSurface); + let rangeEnd = min(surfaceDistance, fog.march.y); + let rangeStart = min(fog.medium.w, rangeEnd); + let steps = i32(fog.march.x); + let stepLength = (rangeEnd - rangeStart) / f32(steps); + if stepLength <= 0.0 { return vec4f(0.0); } + let worldDirection = normalize((fog.inverseView * vec4f(viewRay, 0.0)).xyz); + let origin = fog.inverseView[3].xyz; + let phase = henyeyGreenstein(dot(worldDirection, fog.sunDirection.xyz), fog.sunDirection.w); + let jitter = fract(interleavedGradientNoise(input.position.xy) + fog.march.z); + var transmittance = 1.0; + var scattered = 0.0; + for (var index = 0; index < 64; index++) { + if index >= steps { break; } + let travel = rangeStart + (f32(index) + jitter) * stepLength; + let world = origin + worldDirection * travel; + let density = fog.medium.x * exp(clamp(-fog.medium.y * (world.y - fog.medium.z), -60.0, 60.0)); + let opticalStep = density * stepLength; + transmittance *= exp(-opticalStep); + scattered += opticalStep * transmittance * sunVisibility(world, travel * viewRay.z); + } + return vec4f(fog.sunRadiance.rgb * (scattered * phase), 1.0); +} diff --git a/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_common.wgsl b/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_common.wgsl new file mode 100644 index 000000000..46cc9ef6b --- /dev/null +++ b/Sources/AdaCorePipelines/Assets/Shaders/volumetric_fog_common.wgsl @@ -0,0 +1,80 @@ +// Shared ABI of the global and bounded/local volumetric passes. +struct VolumetricFog3DUniform { + inverseView: mat4x4f, + inverseProjection: mat4x4f, + shadowViewProjection0: mat4x4f, + shadowViewProjection1: mat4x4f, + shadowViewProjection2: mat4x4f, + cascadeSplits: vec4f, + shadowAtlas: vec4f, + sunDirection: vec4f, + sunRadiance: vec4f, + medium: vec4f, + march: vec4f, +} + +@group(0) @binding(0) var positionImage: texture_2d; +@group(0) @binding(1) var directionalShadowImage: texture_2d; +@group(0) @binding(2) var nearestSampler: sampler; +@group(0) @binding(4) var fog: VolumetricFog3DUniform; + +const PI = 3.14159265359; + +fn henyeyGreenstein(cosine: f32, g: f32) -> f32 { + let denominator = 1.0 + g * g - 2.0 * g * cosine; + return (1.0 - g * g) / (4.0 * PI * pow(max(denominator, 0.0001), 1.5)); +} + +fn cascadeIndex(viewDepth: f32) -> i32 { + let candidate = select(select(2, 1, viewDepth <= fog.cascadeSplits.y), 0, viewDepth <= fog.cascadeSplits.x); + return min(candidate, i32(fog.cascadeSplits.w) - 1); +} + +fn cascadeTransform(cascade: i32) -> mat4x4f { + if cascade == 0 { return fog.shadowViewProjection0; } + if cascade == 1 { return fog.shadowViewProjection1; } + return fog.shadowViewProjection2; +} + +fn loadNearestShadow(image: texture_2d, uv: vec2f) -> f32 { + let size = vec2i(textureDimensions(image, 0)); + let pixel = clamp(vec2i(uv * vec2f(size)), vec2i(0), size - 1); + return textureLoad(image, pixel, 0).r; +} + +fn shadowDepth(localUV: vec2f, cascade: i32) -> f32 { + let count = max(fog.cascadeSplits.w, 1.0); + let uv = vec2f((localUV.x + f32(cascade)) / count, localUV.y); + let minimum = vec2f(f32(cascade) / count, 0.0) + fog.shadowAtlas.xy * 0.5; + let maximum = vec2f(f32(cascade + 1) / count, 1.0) - fog.shadowAtlas.xy * 0.5; + return loadNearestShadow(directionalShadowImage, clamp(uv, minimum, maximum)); +} + +fn outsideShadow(projected: vec3f, localUV: vec2f) -> bool { + return projected.z < 0.0 || projected.z > 1.0 || any(localUV < vec2f(0.0)) || any(localUV > vec2f(1.0)); +} + +fn sunVisibility(world: vec3f, viewDepth: f32) -> f32 { + if fog.shadowAtlas.w < 0.5 || viewDepth > fog.cascadeSplits.z { return 1.0; } + let cascade = cascadeIndex(viewDepth); + let position = cascadeTransform(cascade) * vec4f(world, 1.0); + let projected = position.xyz / position.w; + let localUV = projected.xy * vec2f(0.5, -0.5) + 0.5; + if outsideShadow(projected, localUV) { return 1.0; } + let stored = shadowDepth(localUV, cascade); + return select(0.0, 1.0, projected.z - fog.shadowAtlas.z <= stored); +} + +// Signed world-depth separation, including orthographic depth-row scaling. +fn sunShadowGap(world: vec3f, viewDepth: f32) -> f32 { + if fog.shadowAtlas.w < 0.5 || viewDepth > fog.cascadeSplits.z { return 10000.0; } + let cascade = cascadeIndex(viewDepth); + let transform = cascadeTransform(cascade); + let position = transform * vec4f(world, 1.0); + let projected = position.xyz / position.w; + let localUV = projected.xy * vec2f(0.5, -0.5) + 0.5; + if outsideShadow(projected, localUV) { return 10000.0; } + let stored = shadowDepth(localUV, cascade); + let depthRow = vec3f(transform[0][2], transform[1][2], transform[2][2]); + return (stored + fog.shadowAtlas.z - projected.z) / max(length(depthRow), 0.00000001); +} diff --git a/Sources/AdaCorePipelines/CorePipelines/DynamicIrradiance3D.swift b/Sources/AdaCorePipelines/CorePipelines/DynamicIrradiance3D.swift index 8dd9025f4..cc72f0605 100644 --- a/Sources/AdaCorePipelines/CorePipelines/DynamicIrradiance3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/DynamicIrradiance3D.swift @@ -117,10 +117,12 @@ struct DynamicIrradiance3DViews: Resource { var nearest: (any Sampler)? mutating func getPipeline(_ device: RenderDevice) -> any RenderPipeline { if let pipeline { return pipeline } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/dynamic_irradiance_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("dynamic_irradiance_3d") var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), debugName: "World probe relighting", backfaceCulling: false, depthPixelFormat: .none) descriptor.colorAttachments = [.init(format: .rgba_16f)] + // Probe inputs and the R32Float shadow atlas are fetched by integer texel coordinate. + descriptor.unfilterableTextureBindings = [0: [0, 1, 2, 3, 6, 26]] let result = device.createRenderPipeline(from: descriptor) pipeline = result; nearest = device.createSampler(from: .init(minFilter: .nearest, magFilter: .nearest, mipFilter: .notMipmapped)) return result diff --git a/Sources/AdaCorePipelines/CorePipelines/GlassRenderPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/GlassRenderPipeline.swift index a39043adf..4da820a19 100644 --- a/Sources/AdaCorePipelines/CorePipelines/GlassRenderPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/GlassRenderPipeline.swift @@ -70,7 +70,7 @@ public struct GlassPipeline: RenderPipelineConfigurator { private let shader: AssetHandle public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/glass.glsl", from: .adaModule) + self.shader = ShaderModule.loadRequiredBuiltin(at: "Shaders/glass", from: .adaModule) } public func configurate( diff --git a/Sources/AdaCorePipelines/CorePipelines/MeshletMap3D.swift b/Sources/AdaCorePipelines/CorePipelines/MeshletMap3D.swift index e985d8ab5..15727f445 100644 --- a/Sources/AdaCorePipelines/CorePipelines/MeshletMap3D.swift +++ b/Sources/AdaCorePipelines/CorePipelines/MeshletMap3D.swift @@ -130,7 +130,8 @@ public struct MeshletMap3DResources: Resource { var defines: [String] = [] if key.response { defines.append("MAP_RESPONSE") } if key.surface { defines.append("MAP_SURFACE") } - let shader = try ShaderModule.createBundled(at: "Shaders/meshlet_map_3d.glsl", from: .adaModule, defines: defines) + let path = ShaderModule.supportsBundledWESL ? "Shaders/meshlet_map_3d.wesl" : "Shaders/meshlet_map_3d.glsl" + let shader = try ShaderModule.createBundled(at: path, from: .adaModule, defines: defines) var vertex = VertexDescriptor() vertex.attributes[0] = .attribute(.vector4, name: "positionAndClusterID", bufferIndex: 0, offset: 0) vertex.layouts[0] = .init(stride: MemoryLayout.stride, stepFunction: .perVertex) diff --git a/Sources/AdaCorePipelines/CorePipelines/PixelPerfectPresentation.swift b/Sources/AdaCorePipelines/CorePipelines/PixelPerfectPresentation.swift index b8141f5c6..cdc9a2afe 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PixelPerfectPresentation.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PixelPerfectPresentation.swift @@ -16,7 +16,7 @@ public struct PixelPerfectPipelines: Resource { public let sampler: Sampler public init(device: RenderDevice, outputPipeline: RenderPipeline) { - let shader = ShaderModule.loadRequiredBundled(at: "Shaders/FullScreenShader.glsl", from: .adaModule) + let shader = CorePipelineShaders.loadRequiredBuiltin("FullScreenShader") var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex)) descriptor.debugName = "Pixel Perfect Intermediate Pipeline" descriptor.backfaceCulling = false diff --git a/Sources/AdaCorePipelines/CorePipelines/PortableTemporal3DUpscaler.swift b/Sources/AdaCorePipelines/CorePipelines/PortableTemporal3DUpscaler.swift index 65ab4d8ce..1c15099a6 100644 --- a/Sources/AdaCorePipelines/CorePipelines/PortableTemporal3DUpscaler.swift +++ b/Sources/AdaCorePipelines/CorePipelines/PortableTemporal3DUpscaler.swift @@ -22,11 +22,13 @@ final class PortableTemporal3DUpscaler: TemporalUpscaler, @unchecked Sendable { init(device: RenderDevice, inputSize: SizeInt, outputSize: SizeInt, view: Entity.ID) { self.device = device; self.view = view; self.inputSize = inputSize; self.outputSize = outputSize - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/portable_temporal_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("portable_temporal_3d") var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), debugName: "Portable temporal reconstruction", backfaceCulling: false, depthPixelFormat: .none) descriptor.colorAttachments = [.init(format: .rgba_16f), .init(format: .rgba_16f), .init(format: .r32f)] + // The R32Float history is read with textureLoad, so browsers need no float32 filtering feature. + descriptor.unfilterableTextureBindings = [0: [5]] pipeline = device.createRenderPipeline(from: descriptor) sampler = device.createSampler(from: .init(minFilter: .linear, magFilter: .linear, mipFilter: .notMipmapped)) nearest = device.createSampler(from: .init(minFilter: .nearest, magFilter: .nearest, mipFilter: .notMipmapped)) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/AutoExposure3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/AutoExposure3DRenderNode.swift index 1d2ddf9e4..4103d9298 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/AutoExposure3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/AutoExposure3DRenderNode.swift @@ -139,7 +139,7 @@ struct AutoExposure3DViews: Resource { private var retired: [[RenderTexture]] = [] init(device: RenderDevice) { - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/auto_exposure_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("auto_exposure_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Bloom3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Bloom3DRenderNode.swift index c6f01d45c..ca1808214 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Bloom3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Bloom3DRenderNode.swift @@ -126,7 +126,7 @@ struct Bloom3DViews: Resource { private var retired: [[RenderTexture]] = [] init(device: RenderDevice) { - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/bloom_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("bloom_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/CameraEffects3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/CameraEffects3DRenderNode.swift index e7fe3c028..5b68f4828 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/CameraEffects3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/CameraEffects3DRenderNode.swift @@ -192,7 +192,7 @@ struct CameraEffects3DViews: Resource { mutating func pipeline(device: RenderDevice) -> RenderPipeline { if let renderPipeline { return renderPipeline } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/camera_effects_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("camera_effects_3d") var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), debugName: "Camera Effects 3D", backfaceCulling: false, depthPixelFormat: .none) descriptor.colorAttachments = [.init(format: .bgra8)] diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DebugView3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DebugView3DRenderNode.swift index cbf1f10cc..5abdbc318 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DebugView3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DebugView3DRenderNode.swift @@ -98,7 +98,7 @@ struct DebugView3DPipelines: Resource { if let cached { return cached } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/debug_view_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("debug_view_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DepthOfField3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DepthOfField3DRenderNode.swift index 55482bdcd..b79880a6e 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DepthOfField3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/DepthOfField3DRenderNode.swift @@ -116,7 +116,7 @@ struct DepthOfField3DViews: Resource { private var retired: [RenderTexture] = [] init(device: RenderDevice) { - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/depth_of_field_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("depth_of_field_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift index 2bc51f5dc..979bdf81d 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/LocalShadow3DRenderNode.swift @@ -26,7 +26,7 @@ struct LocalShadow3DClear: Resource { let pipeline: RenderPipeline init(device: RenderDevice) { - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/shadow_clear_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("shadow_clear_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift index e5bc6b320..4a71acb80 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Main3DRenderNode.swift @@ -306,7 +306,7 @@ public struct Main3DRenderNode: RenderNode { contact: [contactEnabled ? 1 : 0, contact.rayLength, contact.thickness, contact.bias], quality: [Float(contact.steps), contact.maxDistance, directionalLight.castsShadows ? 1 : 0, 0], shadowFilter: [shadowSettings.filter == .pcss ? 1 : 0, - tan(finiteRenderValue(shadowSettings.lightAngularRadius, fallback: 0.27, range: 0...10) * .pi / 180), + Math.tanf(finiteRenderValue(shadowSettings.lightAngularRadius, fallback: 0.27, range: 0...10) * .pi / 180), finiteRenderValue(shadowSettings.maximumPenumbraTexels, fallback: 16, range: 1...32), finiteRenderValue(shadowSettings.blockerSearchTexels, fallback: 8, range: 1...32)]), view: source.entityId, pass: uniformPass, device: renderDevice.renderDevice) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Particles3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Particles3DRenderNode.swift index cb376a5c4..05bf0e1ae 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Particles3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/Particles3DRenderNode.swift @@ -141,7 +141,8 @@ struct Particles3DResources: Resource { var defines: [String] = [] if attachments >= 2 { defines.append("PARTICLE_INDIRECT") } if attachments >= 3 { defines.append("PARTICLE_REFLECTION_RESPONSE") } - let shader = try ShaderModule.createBundled(at: "Shaders/particles_3d.glsl", from: .adaModule, defines: defines) + let path = ShaderModule.supportsBundledWESL ? "Shaders/particles_3d.wesl" : "Shaders/particles_3d.glsl" + let shader = try ShaderModule.createBundled(at: path, from: .adaModule, defines: defines) var vertex = VertexDescriptor() vertex.attributes[0] = .attribute(.vector4, name: "centerSize", bufferIndex: 0, offset: 0) vertex.attributes[1] = .attribute(.vector4, name: "color", bufferIndex: 0, offset: 16) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift index aa2a994b2..5ec35277c 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/PathTracing3DRenderNode.swift @@ -88,7 +88,7 @@ struct PathTracing3DViews: Resource { mutating func getPipeline(_ device: RenderDevice) -> any RenderPipeline { if let pipeline { return pipeline } - let shader = CorePipelineShaders.loadRequiredBundled(at:"Shaders/path_tracing_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("path_tracing_3d") var descriptor = RenderPipelineDescriptor(vertex:shader.asset.requiredShader(for:.vertex),fragment:shader.asset.getShader(for:.fragment), debugName:"World-space path tracing",backfaceCulling:false,depthPixelFormat:.none) descriptor.colorAttachments = [.init(format:.rgba_16f)] @@ -186,7 +186,7 @@ public struct PathTracing3DRenderNode: RenderNode { for sun in suns where sun.intensity.isFinite && sun.intensity > 0 && LocalLighting3DMath.isFinite(sun.directionToLight) && sun.directionToLight.squaredLength > 0.000001 && LocalLighting3DMath.isFinite(sun.radiance) { let angle = finiteRenderValue(environment.shadows.lightAngularRadius,fallback:0.27,range:0...10)*Float.pi/180 - lights += [Vector4(sun.directionToLight.normalized,0),Vector4(sun.radiance*sun.intensity,0),Vector4(0,0,0,sun.castsShadows ? 1 : 0),Vector4(0,0,tan(angle),0)] + lights += [Vector4(sun.directionToLight.normalized,0),Vector4(sun.radiance*sun.intensity,0),Vector4(0,0,0,sun.castsShadows ? 1 : 0),Vector4(0,0,Math.tanf(angle),0)] } for lamp in lighting.localLights where lamp.intensity.isFinite && lamp.intensity > 0 && lamp.range.isFinite && lamp.range > 0 && LocalLighting3DMath.isFinite(lamp.position) && LocalLighting3DMath.isFinite(lamp.radiance) { diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceGI3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceGI3DRenderNode.swift index 604026bfd..a68b25405 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceGI3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/ScreenSpaceGI3DRenderNode.swift @@ -102,7 +102,7 @@ struct ScreenSpaceGI3DViews: Resource { } mutating func getPipeline(_ device: RenderDevice) -> any RenderPipeline { if let pipeline { return pipeline } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/screen_space_gi_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("screen_space_gi_3d") var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), debugName: "SSGI diffuse transport", backfaceCulling: false, depthPixelFormat: .none) descriptor.colorAttachments = [.init(format: .rgba_16f)] diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift index 0e5a2f146..a3f2e577c 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderNodes3D/VolumetricFog3DRenderNode.swift @@ -157,7 +157,7 @@ struct VolumetricFog3DViews: Resource { private var retired: [RenderTexture] = [] init(device: RenderDevice) { - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/volumetric_fog_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("volumetric_fog_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), @@ -166,17 +166,19 @@ struct VolumetricFog3DViews: Resource { depthPixelFormat: .none ) descriptor.colorAttachments = [.init(format: .rgba_16f)] + descriptor.unfilterableTextureBindings = [0: [1]] pipeline = device.createRenderPipeline(from: descriptor) nearest = device.createSampler(from: .init(minFilter: .nearest, magFilter: .nearest, mipFilter: .notMipmapped)) } mutating func localLightingPipeline(device: RenderDevice) -> any RenderPipeline { if let localPipeline { return localPipeline } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/local_volumetric_fog_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("local_volumetric_fog_3d") var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), debugName: "Local volumetric fog", backfaceCulling: false, depthPixelFormat: .none) descriptor.colorAttachments = [.init(format: .rgba_16f)] + descriptor.unfilterableTextureBindings = [0: [1, 26]] let pipeline = device.createRenderPipeline(from: descriptor) localPipeline = pipeline return pipeline diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift index 278c4ce06..df4079b56 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/DirectionalShadow3DPipeline.swift @@ -21,7 +21,7 @@ public struct DirectionalShadow3DPipeline: RenderPipelineConfigurator { private let variants = PBR3DShaderVariants() public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/directional_shadow_3d.glsl", from: .adaModule) + self.shader = CorePipelineShaders.loadRequiredBuiltin("directional_shadow_3d") } /// The pipeline for a part without morph targets. @@ -31,7 +31,11 @@ public struct DirectionalShadow3DPipeline: RenderPipelineConfigurator { public func configurate(with shadowConfiguration: Shadow3DConfiguration) -> RenderPipelineDescriptor { let shader = shadowConfiguration.morph - ? variants.shader(at: "Shaders/directional_shadow_3d.glsl", for: .morph, fallback: self.shader) + ? variants.shader( + at: ShaderModule.supportsBundledWESL ? "Shaders/directional_shadow_3d.wesl" : "Shaders/directional_shadow_3d.glsl", + for: .morph, + fallback: self.shader + ) : self.shader var configuration = shadowConfiguration.vertex configureSkinningAttributes(&configuration) diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift index c58511565..cf5b1b2fd 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Flat3DPipeline.swift @@ -161,8 +161,18 @@ public struct Flat3DPipeline: RenderPipelineConfigurator { private let shader: AssetHandle private let variants = PBR3DShaderVariants() + /// The WESL source where the renderer links it at runtime; the GLSL source otherwise. + /// The web export does not prelink flat3d variants yet, so WebAssembly keeps the GLSL path. + public static var shaderPath: String { + #if WASM + return "Shaders/flat3d.glsl" + #else + return ShaderModule.supportsBundledWESL ? "Shaders/flat3d.wesl" : "Shaders/flat3d.glsl" + #endif + } + public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/flat3d.glsl", from: .adaModule) + self.shader = ShaderModule.loadRequiredBundled(at: Self.shaderPath, from: .adaModule) } public func configurate(with configuration: VertexDescriptor) -> RenderPipelineDescriptor { @@ -234,7 +244,7 @@ final class PBR3DShaderVariants: @unchecked Sendable { private var variants: [Key: AssetHandle] = [:] func shader( - at path: String = "Shaders/flat3d.glsl", + at path: String = Flat3DPipeline.shaderPath, for features: PBRMaterial.ShaderFeatures, fallback: AssetHandle ) -> AssetHandle { diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift index c6e365642..83cfa19d3 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/Motion3DPipeline.swift @@ -16,10 +16,11 @@ public struct Motion3DPipeline: RenderPipelineConfigurator { public init() {} public func configurate(with configuration: PBR3DConfiguration) -> RenderPipelineDescriptor { - let plain = CorePipelineShaders.loadRequiredBundled(at: "Shaders/motion_3d.glsl") + let plain = CorePipelineShaders.loadRequiredBuiltin("motion_3d") // Only the morph feature matters to the motion shader; the shading layers do not. let morph = configuration.features.intersection(.morph) - let shader = morph.isEmpty ? plain : variants.shader(at: "Shaders/motion_3d.glsl", for: morph, fallback: plain) + let path = ShaderModule.supportsBundledWESL ? "Shaders/motion_3d.wesl" : "Shaders/motion_3d.glsl" + let shader = morph.isEmpty ? plain : variants.shader(at: path, for: morph, fallback: plain) var descriptor = base.configurate(with: configuration.vertex) descriptor.vertex = shader.asset.requiredShader(for: .vertex) descriptor.fragment = shader.asset.getShader(for: .fragment) @@ -84,7 +85,7 @@ struct Temporal3DPipelines: Resource { if let sky { return sky } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/sky_motion_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("sky_motion_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), @@ -102,7 +103,7 @@ struct Temporal3DPipelines: Resource { if let toneMap { return toneMap } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/temporal_tonemap_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("temporal_tonemap_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), @@ -120,7 +121,7 @@ struct Temporal3DPipelines: Resource { if let depth { return depth } - let shader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/temporal_depth_3d.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin("temporal_depth_3d") var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenQuality3DPipelines.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenQuality3DPipelines.swift index 9ba184c81..9991c8b78 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenQuality3DPipelines.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenQuality3DPipelines.swift @@ -18,7 +18,8 @@ struct ScreenQuality3DPipelines: Resource { if let ao { return ao } - let pipeline = make("Shaders/ssao_3d.glsl", label: "SSAO 3D", device: device) + let shader = CorePipelineShaders.loadRequiredBuiltin("ssao_3d") + let pipeline = make(shader, label: "SSAO 3D", device: device) ao = pipeline return pipeline } @@ -27,13 +28,13 @@ struct ScreenQuality3DPipelines: Resource { if let aa { return aa } - let pipeline = make("Shaders/fxaa_3d.glsl", label: "FXAA 3D", device: device) + let shader = CorePipelineShaders.loadRequiredBuiltin("fxaa_3d") + let pipeline = make(shader, label: "FXAA 3D", device: device) aa = pipeline return pipeline } - private func make(_ path: String, label: String, device: RenderDevice) -> RenderPipeline { - let shader = CorePipelineShaders.loadRequiredBundled(at: path) + private func make(_ shader: AssetHandle, label: String, device: RenderDevice) -> RenderPipeline { var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift index 11edb958f..bfb85b421 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ScreenSpaceReflectionPipeline.swift @@ -20,8 +20,9 @@ public struct ScreenSpaceReflectionPipeline: Resource { } public init(device: RenderDevice, pathTracingPresentation: Bool) { - let shader = CorePipelineShaders.loadRequiredBundled(at: pathTracingPresentation - ? "Shaders/path_tracing_composite_3d.glsl" : "Shaders/screen_space_reflection.glsl") + let shader = CorePipelineShaders.loadRequiredBuiltin( + pathTracingPresentation ? "path_tracing_composite_3d" : "screen_space_reflection" + ) var descriptor = RenderPipelineDescriptor( vertex: shader.asset.requiredShader(for: .vertex), fragment: shader.asset.getShader(for: .fragment), diff --git a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ShaderGraph3DPipeline.swift b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ShaderGraph3DPipeline.swift index a293c62c7..c24c83d5f 100644 --- a/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ShaderGraph3DPipeline.swift +++ b/Sources/AdaCorePipelines/CorePipelines/RenderPipelines3D/ShaderGraph3DPipeline.swift @@ -1,4 +1,5 @@ import AdaRender +import AdaUtils import Foundation public enum ShaderGraph3DPipeline { @@ -19,7 +20,7 @@ public enum ShaderGraph3DPipeline { let pipeline = try make( material: graph, base: base, device: device, variant: "shadow:\(formats):\(base.descriptor.depthPixelFormat):\(morph)", - features: morph ? .morph : [], template: "Shaders/directional_shadow_3d.glsl" + features: morph ? .morph : [], template: surfaceTemplatePath("directional_shadow_3d") ) encoder.setRenderPipelineState(pipeline) graph.bind(to: encoder, device: device) @@ -36,14 +37,53 @@ public enum ShaderGraph3DPipeline { device: RenderDevice, variant: String, features: PBRMaterial.ShaderFeatures = [], - template: String = "Shaders/flat3d.glsl" + template: String = Flat3DPipeline.shaderPath ) throws -> RenderPipeline { guard material.program.target == .pbrSurface else { throw ShaderGraphDiagnostic(code: .typeMismatch, nodeID: "PBR") } - return try material.pipeline(base: base.descriptor, device: device, variant: variant, defines: features.enablingMacros.map { .define($0) }) { - try fragmentTemplate(path: template) + let name = URL(fileURLWithPath: template).deletingPathExtension().lastPathComponent + let macros = features.enablingMacros.map { ShaderDefine.define($0) } + #if WASM + // A WebAssembly build links no shaders: it uses the fragment AdaShaderGraphBaker linked from the WESL template. + return try material.weslPipeline(base: base.descriptor, device: device, variant: variant, defines: macros, templateName: name) { + "" + } + #else + // WESL templates take the graph as WGSL and link at runtime; GLSL templates keep the GLSL form. + if template.hasSuffix(".wesl") { + return try material.weslPipeline( + base: base.descriptor, device: device, variant: variant, defines: macros, + packageRoot: weslPackageRoot, + templateName: name + ) { + try weslTemplate(path: template) + } + } + return try material.pipeline(base: base.descriptor, device: device, variant: variant, defines: macros) { + try fragmentTemplate(path: template) + } + #endif + } + + /// Where the surface templates' `package::` imports resolve. + public static var weslPackageRoot: URL { + Bundle.adaModule.resourceURL?.appendingPathComponent("Shaders", isDirectory: true) ?? URL(fileURLWithPath: "/") + } + + /// A surface template in the language the active renderer links: WESL where it is supported, GLSL otherwise. + public static func surfaceTemplatePath(_ name: String) -> String { + Flat3DPipeline.shaderPath.hasSuffix(".wesl") ? "Shaders/\(name).wesl" : "Shaders/\(name).glsl" + } + + /// The WESL source of a surface template; its `// ADA_GRAPH_SOURCE_MARKER` line receives the graph's WGSL. + public static func weslTemplate(path: String = "Shaders/flat3d.wesl") throws -> String { + guard let url = Bundle.adaModule.resourceURL?.appendingPathComponent(path), + let data = FileSystem.current.readFile(at: url), + let source = String(bytes: data, encoding: .utf8) else { + throw ShaderGraphDiagnostic(code: .invalidReference, nodeID: path) } + return source } /// The production surface/silhouette fragment used by offline graph export. diff --git a/Sources/AdaCorePipelines/CorePipelines/UIRenderPipelines.swift b/Sources/AdaCorePipelines/CorePipelines/UIRenderPipelines.swift index c27dd42ec..2647cbe9d 100644 --- a/Sources/AdaCorePipelines/CorePipelines/UIRenderPipelines.swift +++ b/Sources/AdaCorePipelines/CorePipelines/UIRenderPipelines.swift @@ -17,6 +17,11 @@ public enum CorePipelineShaders { try ShaderModule.loadBundled(at: path, from: .adaModule) } + /// Loads a migrated built-in shader using the active backend's supported format. + public static func loadRequiredBuiltin(_ name: String) -> AssetHandle { + ShaderModule.loadRequiredBuiltin(at: "Shaders/\(name)", from: .adaModule) + } + public static func loadRequiredBundled(at path: String) -> AssetHandle { ShaderModule.loadRequiredBundled(at: path, from: .adaModule) } @@ -29,7 +34,7 @@ public struct QuadPipeline: RenderPipelineConfigurator { private let shader: AssetHandle public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/quad.glsl", from: .adaModule) + self.shader = CorePipelineShaders.loadRequiredBuiltin("quad") } public func configurate( @@ -64,7 +69,7 @@ public struct LinearGradientPipeline: RenderPipelineConfigurator { private let shader: AssetHandle public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/gradient.glsl", from: .adaModule) + self.shader = CorePipelineShaders.loadRequiredBuiltin("gradient") } public func configurate( @@ -99,7 +104,7 @@ public struct CirclePipeline: RenderPipelineConfigurator { private let shader: AssetHandle public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/circle.glsl", from: .adaModule) + self.shader = CorePipelineShaders.loadRequiredBuiltin("circle") } public func configurate( @@ -136,7 +141,7 @@ public struct LinePipeline: RenderPipelineConfigurator { private let shader: AssetHandle public init() { - self.shader = ShaderModule.loadRequiredBundled(at: "Shaders/line.glsl", from: .adaModule) + self.shader = CorePipelineShaders.loadRequiredBuiltin("line") } public func configurate( diff --git a/Sources/AdaCorePipelines/CorePipelines/Upscaling.swift b/Sources/AdaCorePipelines/CorePipelines/Upscaling.swift index 77089cb36..94ccd5c32 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Upscaling.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Upscaling.swift @@ -36,7 +36,7 @@ public struct UpscalePipeline: Resource { public let sampler: Sampler public init(device: RenderDevice) { - let spriteShader = ShaderModule.loadRequiredBundled(at: "Shaders/FullScreenShader.glsl", from: .adaModule) + let spriteShader = CorePipelineShaders.loadRequiredBuiltin("FullScreenShader") var descriptor = RenderPipelineDescriptor(vertex: spriteShader.asset.requiredShader(for: .vertex)) descriptor.debugName = "Upscale Pipeline" diff --git a/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md b/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md index 4b0276483..d10ae2820 100644 --- a/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md +++ b/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md @@ -45,3 +45,4 @@ For more details and guides, check out the tutorials below. - - - +- diff --git a/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/WESLShaders.md b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/WESLShaders.md new file mode 100644 index 000000000..16b3dff90 --- /dev/null +++ b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/WESLShaders.md @@ -0,0 +1,120 @@ +# Compile modular WESL shaders + +Link local shader modules and compile feature variants to WGSL with the AdaEngine host tool. + +AdaEngine links the modules in Swift and validates the result with the local SAGACompiler. The shader syntax follows the [WESL specification snapshot](https://github.com/webgpu-tools/wesl-spec/tree/37bf3f3a2164781040e7a42fc6529cb34e2c059b). + +## Link two shader files + +Save a helper as `shaders/common.wesl`. + +```wgsl +fn shade() -> vec4f { return vec4f(1); } +``` + +Save the entry module as `shaders/main.wesl`. + +```wgsl +import package::common::shade as baseColor; + +@if(textured) +@group(0) @binding(0) var image: texture_2d; + +@fragment fn fragmentMain() -> @location(0) vec4f { + @if(textured) + return textureLoad(image, vec2i(0), 0) * baseColor(); + @else + return baseColor(); +} +``` + +Enable the host export tools and compile both variants. + +```sh +export ADAENGINE_WEB_EXPORT=1 +swift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled +swift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled --feature textured +``` + +In PowerShell, set the variable with `$env:ADAENGINE_WEB_EXPORT = '1'`. +The tool writes `compiled/main.wgsl` and `compiled/main.textured.wgsl`. The textured variant declares a texture at group 0, binding 0. Use `--wesl-root` when the main module lives below the package root. Use `--wesl-dependency name=directory` to register a package for imports starting with `name::`. Inside that dependency, `package::` refers to its own root. + +On SAGA and native WebGPU builds with WESL support, `ShaderModule.create(from:)` loads the default variant from a `.wesl` file. Use `ShaderModule.createWESL(from:packageRoot:features:)` to supply a package root or enable features. A shader module can have one entry point per active stage. The browser uses linked WGSL, and SAGA compiles the same linked source to MSL for Metal or SPIR-V for Vulkan. SAGAMetal is the default renderer on macOS, iOS, and tvOS. + +When porting a GLSL material, move its sampler and texture into separate WGSL bindings and pass vertex outputs explicitly between stages. The fragment example above uses `textureLoad` for integer texel coordinates. For normalized, filtered sampling, declare a `sampler` at a separate binding and use `textureSample(image, imageSampler, uv)`. Update the material's binding numbers to match the WESL declarations and compile every feature combination the material uses. + +The built-in quad, circle, line, and linear gradient use WESL on WebGPU. The sprite, instanced sprite, Spine, text, and tilemap pipelines also use WESL on WebGPU. These shaders import the shared view projection from `ada_engine::view`. The runtime and web exporter register this package from the AdaRender shader resources. The 2D composite, point, and directional lighting passes share their fullscreen vertex stage through `light2d_fullscreen.wgsl`. Web export links their default variants on the host and packages the WGSL stages for browser startup. In automatic web export, the entry file's directory is the local package root. Backends without WESL support load the corresponding GLSL shaders. + +Fullscreen presentation, local shadow atlas clears, and temporal tone mapping use a common fullscreen vertex module. The temporal tone mapping pass imports the output transform and color grading modules. Sky motion, FXAA, SSAO, the 3D debug view, and temporal output depth also use the fullscreen module. The depth pass and portable temporal reconstruction use depth texture bindings and integer texel loads. Reconstruction reads its R32Float history without filtering. Bloom, automatic exposure, depth of field, and camera effects use the same fullscreen vertex module. Camera effects also use the shared display dithering function. + +The screen-space reflection pass and path-tracing composite import environment fog, exposure, bloom, and grading from the shared composite module. + +Directional shadows and motion vectors use WESL base shaders and `ADA_MORPH` variants. They share mesh morphing, face and fade visibility, and base-color alpha sampling. Web export packages the linked variant with its feature name, for example `directional_shadow_3d.wesl.ADA_MORPH.vert.wgsl`. + +Dynamic irradiance probe updates use integer texel reads for transport, history, and the local shadow atlas. + +The meshlet map exports the base shader and the three combinations of its reflection response and surface attachments. + +The linker reports linking errors with the affected file path. This initial implementation supports explicit local imports and conditional declarations. It rejects cyclic imports and local names that shadow linked bindings. Web export emits named variants for built-in pipelines that declare them. Caching linked artifacts and remapping compiler diagnostics to the original source are pending. + + +## Emit native backend code with SAGA + +The host tool can compile linked WESL directly through SAGA to Metal source or binary SPIR-V. +Enable the host tools with `ADAENGINE_WEB_EXPORT=1`, then use the same source package and feature flags: + +```sh +swift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled --target msl +swift run AdaShaderTranspilerTool --input shaders/main.wesl --output-directory compiled --target spirv --feature textured +``` + +For the fragment-only example above these commands write `main.frag.msl` and +`main.textured.frag.spv`. Vertex and compute stages use `.vert` and `.comp` suffixes. +SPIR-V output is a little-endian binary module, not a GLSL string or a WGSL sidecar. +The default target remains WGSL. `--web-stages` is only valid with WGSL output. +A bundled module must have at most one entry point of each stage; the tool compiles every stage +before writing the native batch, so a compiler failure does not publish a partial batch. +Filesystem failures during writing can still leave individual completed files. + +For in-process integration, `WESLBackendCompiler.compile(_:to:)` takes the artifact returned by +`WESLHostCompiler.compile` and returns each emitted stage, its source and generated entry-point names, +its code bytes and its full SAGA render/compute reflection. Use the reflected Metal buffer, texture +and sampler indices: WGSL binding numbers are not necessarily Metal resource slots. + +Custom `ReflectedMaterial` types may override `shaderModule()` to supply a complete module instead of +separate legacy vertex/fragment sources. The default returns `nil`, preserving the current source path. +`CustomMaterial` retains a supplied module and uses per-instance material identity; it does not recompile +its stages as GLSL. Such a module must already have the correct reflection and selected feature variant: +passing shader defines afterward is rejected, and serializing a module-backed material without its +original source is unsupported. Hook loading failures propagate when creating the material's module. +The native WebGPU WESL loader now reads SAGA-validated IR for uniform-buffer +reachability and minimum byte sizes, including matrix padding. This layout query does not regenerate +WGSL: it follows function calls and global references in the selected entry's IR and uses validated +struct spans and array strides (with WGSL scalar/vector/matrix sizes for non-struct roots). `WESLBackendCompiler.webGPUUniformBuffers` +exposes those logical group/binding layouts without substituting Metal slots. Uniform binding arrays +are rejected until a runtime adapter exists. If a reflected uniform cannot be matched by the transitional +name parser, loading fails rather than allocating a zero-sized buffer. +This is not complete reflection integration: resource names, member metadata and non-uniform resources +still depend on the existing parser; material property-name mapping and browser sidecar metadata remain pending. +The new UI shadow source is therefore not yet selected by the production shadow material. + +These command-line native outputs are code-generation artifacts, **not ready-to-load material packages**. +The command does not serialize the runtime reflection, cache identity, vertex-stream mapping or pipeline state. +The existing runtime still selects GLSL on backends without an integrated WESL loader. +Emitting or compiling Metal source does not prove draw-time binding correctness or pixel parity. + +### Full GLSL removal is an in-progress breaking migration + +The approved destination is WESL/WGSL input, SAGA compilation and reflection, and no first-party GLSL fallback. +This is not yet a completed cutover: user GLSL inputs remain supported by the old production loader until +its replacement and backend verification land. Do not delete working project assets based only on the new tool option. + +When porting a shader, replace GLSL stage pragmas with WGSL entry-point attributes and use explicit +WESL imports instead of preprocessor includes. Port compile-time interface changes to WESL conditions, +keep buffer byte layouts and material bindings aligned with the host types, then compile and exercise every variant. +For example, the shared `shade` helper and `@fragment` entry point above replace a GLSL include and +fragment `main`; `@if(textured)` replaces a structural preprocessor condition, not a runtime branch. + +Graph-backed Shader Graph assets will be regenerated through the new generator. Archives retaining only +GLSL source will require a manual WESL/WGSL port and rebake; no automatic lossless source conversion is promised. +The final loader diagnostic and archive-version migration are still pending implementation. diff --git a/Sources/AdaGeometry/MeshTerrainPopulationSampling.swift b/Sources/AdaGeometry/MeshTerrainPopulationSampling.swift index 2d6cf970a..f38bfcfec 100644 --- a/Sources/AdaGeometry/MeshTerrainPopulationSampling.swift +++ b/Sources/AdaGeometry/MeshTerrainPopulationSampling.swift @@ -26,8 +26,8 @@ extension MeshTerrainPopulation.CellPass { ) throws -> [MeshTerrainPlacement] { let step = max(rule.spacing * 0.75, 1 / max(1, rule.density).squareRoot()) let input = Sample(rule: rule, patches: patches, influences: influences, coverage: coverage, exclusions: exclusions, halo: halo, step: step) - let low = (0..<3).map { Int(floor(halo.minimum[$0] / step)) } - let high = (0..<3).map { Int(ceil(halo.maximum[$0] / step)) } + let low = (0..<3).map { Int((halo.minimum[$0] / step).rounded(.down)) } + let high = (0..<3).map { Int((halo.maximum[$0] / step).rounded(.up)) } let seed = Self.hash(rule.id.uuidString.utf8.reduce(UInt64(0)) { $0 &* 131 &+ UInt64($1) } ^ rule.seed) var candidates: [Candidate] = [] for axis in 0..<3 { @@ -85,7 +85,7 @@ extension MeshTerrainPopulation.CellPass { guard coverageWeight > 0, try !excluded(point, clearance: rule.sourceExclusionDistance, sources: input.influences, ignoring: ignoredSource) else { return nil } let side = UInt64(normal[axis] < 0 ? 1 : 0) - let stratum = UInt64(bitPattern: Int64(floor(point[axis] / input.step))) + let stratum = UInt64(bitPattern: Int64((point[axis] / input.step).rounded(.down))) let id = Self.hash(lattice ^ stratum &* 0x94D049BB133111EB ^ UInt64(source) ^ side << 48) let probability = min(1, rule.density * coverageWeight * input.step * input.step / max(0.5, largest)) guard Float(id & 0xFFFFFF) / Float(0x1000000) < probability else { return nil } diff --git a/Sources/AdaPlatform/Android/AndroidWindowManager.swift b/Sources/AdaPlatform/Android/AndroidWindowManager.swift index 999d25d8f..c58ef35e2 100644 --- a/Sources/AdaPlatform/Android/AndroidWindowManager.swift +++ b/Sources/AdaPlatform/Android/AndroidWindowManager.swift @@ -111,6 +111,7 @@ final class AndroidRenderSurface: AndroidNativeWindowRenderSurface { private let lease: AndroidNativeWindowLease var nativeWindow: UnsafeMutableRawPointer { unsafe lease.pointer } + var nativeWindowLifetime: any Sendable { lease } var matchesCurrentNativeWindow: Bool { guard let current = unsafe ada_android_acquire_window() else { return false } defer { unsafe ada_android_release_window(current) } diff --git a/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedScreenManager.swift b/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedScreenManager.swift index 6d9d0788d..7d045dba7 100644 --- a/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedScreenManager.swift +++ b/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedScreenManager.swift @@ -54,9 +54,13 @@ } func getBrightness(for screen: Screen) -> Float { - MainActor.assumeIsolated { - Float((screen.systemScreen as? UIScreen)?.brightness ?? 0) - } + #if os(tvOS) + return 1 + #else + return MainActor.assumeIsolated { + Float((screen.systemScreen as? UIScreen)?.brightness ?? 0) + } + #endif } } diff --git a/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedWindowManager.swift b/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedWindowManager.swift index 1b8267e04..58ff3eada 100644 --- a/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedWindowManager.swift +++ b/Sources/AdaPlatform/Apple/AppleEmbedded/AppleEmbeddedWindowManager.swift @@ -120,11 +120,13 @@ systemWindow.rootViewController = gameViewController systemWindow.backgroundColor = .black - if Self.supportsPointerInteraction() { - let pointerInteraction = UIPointerInteraction(delegate: systemWindow) - systemWindow.addInteraction(pointerInteraction) - systemWindow.pointerInteraction = pointerInteraction - } + #if !os(tvOS) + if Self.supportsPointerInteraction() { + let pointerInteraction = UIPointerInteraction(delegate: systemWindow) + systemWindow.addInteraction(pointerInteraction) + systemWindow.pointerInteraction = pointerInteraction + } + #endif window.systemWindow = systemWindow window.minSize = frame.size @@ -265,12 +267,13 @@ private(set) var mouseMode: Input.MouseMode = .visible override func updateCursor() { - guard let window = self.activeWindow?.systemWindow as? _AdaUIWindow else { - return - } + #if !os(tvOS) + guard let window = self.activeWindow?.systemWindow as? _AdaUIWindow else { + return + } - // Causes the interaction to update the pointer in response to an event. - window.pointerInteraction?.invalidate() + window.pointerInteraction?.invalidate() + #endif } override func setCursorImage( @@ -324,13 +327,15 @@ _AdaEngineViewController.idiom(from: .current) } - private static func supportsPointerInteraction() -> Bool { - #if os(iOS) - detectIdiom() == .pad - #else - true - #endif - } + #if !os(tvOS) + private static func supportsPointerInteraction() -> Bool { + #if os(iOS) + detectIdiom() == .pad + #else + true + #endif + } + #endif private func attachWindowToSceneIfNeeded(_ window: UIKit.UIWindow, preferredScene: UIWindowScene? = nil) { let scene: UIWindowScene @@ -353,7 +358,7 @@ } } - final class _AdaUIWindow: UIKit.UIWindow, SystemWindow, UIPointerInteractionDelegate { + final class _AdaUIWindow: UIKit.UIWindow, SystemWindow { public var title: String = "" public var size: Size { @@ -380,7 +385,9 @@ } } - weak var pointerInteraction: UIPointerInteraction? + #if !os(tvOS) + weak var pointerInteraction: UIPointerInteraction? + #endif private var windowManager: AppleEmbeddedWindowManager? init(frame: CGRect, windowManager: AppleEmbeddedWindowManager) { @@ -436,32 +443,24 @@ } #endif - // MARK: - UIPointerInteractionDelegate - - func pointerInteraction(_: UIPointerInteraction, styleFor _: UIPointerRegion) -> UIPointerStyle? { - if windowManager?.getMouseMode() == .hidden { - return UIPointerStyle.hidden() - } + } - var style: UIPointerStyle + #if !os(tvOS) + extension _AdaUIWindow: UIPointerInteractionDelegate { + func pointerInteraction(_: UIPointerInteraction, styleFor _: UIPointerRegion) -> UIPointerStyle? { + if windowManager?.getMouseMode() == .hidden { + return UIPointerStyle.hidden() + } - let cursorShape = windowManager?.getCursorShape() ?? .arrow + let cursorShape = windowManager?.getCursorShape() ?? .arrow - switch cursorShape { - case .iBeam: - style = UIPointerStyle.hidden() - default: - if #available(iOS 15.0, visionOS 1.0, *) { - style = UIPointerStyle.system() - } else { - // Fallback on earlier versions - style = UIPointerStyle.hidden() + if cursorShape != .iBeam, #available(iOS 15.0, visionOS 1.0, *) { + return UIPointerStyle.system() } + return UIPointerStyle.hidden() } - - return style } - } + #endif final class _AdaEngineViewController: UIViewController { var renderView: MetalView diff --git a/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift b/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift index 96ed3096a..f6ed8d300 100644 --- a/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift +++ b/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift @@ -34,6 +34,7 @@ var xlibDisplay: UnsafeMutableRawPointer { unsafe UnsafeMutableRawPointer(display.pointer) } var xlibWindow: UInt64 { UInt64(handle) } + var xlibWindowLifetime: any Sendable { self } var scaleFactor: Float { 1 } var prefferedPixelFormat: PixelFormat { .bgra8 } diff --git a/Sources/AdaPlatform/Linux/LinuxWindowManager.swift b/Sources/AdaPlatform/Linux/LinuxWindowManager.swift index bc6890bcf..aff0121aa 100644 --- a/Sources/AdaPlatform/Linux/LinuxWindowManager.swift +++ b/Sources/AdaPlatform/Linux/LinuxWindowManager.swift @@ -120,7 +120,9 @@ nativeWindows[window.id] = nil renderedSizes[window.id] = nil window.systemWindow = nil - native.destroy() + if unsafe RenderEngine.shared?.type != .saga { + native.destroy() + } unsafe XFlush(display.pointer) } diff --git a/Sources/AdaRender/Assets/Shaders/Materials/circle_canvas_material.wesl b/Sources/AdaRender/Assets/Shaders/Materials/circle_canvas_material.wesl new file mode 100644 index 000000000..7c62442fe --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/Materials/circle_canvas_material.wesl @@ -0,0 +1,33 @@ +import ada_engine::mesh2d::{Mesh2dVertexIn, Mesh2dVertexOut, mesh2dVertex}; + +// WESL port of circle_canvas_material.glsl. + +struct CircleCanvasMaterial { + u_Fade: f32, + u_Thickness: f32, + u_Color: vec4f, +} + +@group(0) @binding(0) var material: CircleCanvasMaterial; + +@vertex +fn mesh_vertex(input: Mesh2dVertexIn) -> Mesh2dVertexOut { + return mesh2dVertex(input); +} + +@fragment +fn circle_canvas_mesh_fragment(input: Mesh2dVertexOut) -> @location(0) vec4f { + let fade = material.u_Fade; + let thickness = material.u_Thickness; + + let uv = input.uv - vec2f(0.5); + + let dist = sqrt(dot(uv * 2.0, uv * 2.0)); // looks like we should pass local position))) + if dist > 1.0 || dist < 1.0 - thickness - fade { + discard; + } + + var alpha = 1.0 - smoothstep(1.0 - fade, 1.0, dist); + alpha = alpha * smoothstep(1.0 - thickness - fade, 1.0 - thickness, dist); + return vec4f(material.u_Color.rgb, alpha); +} diff --git a/Sources/AdaRender/Assets/Shaders/Materials/color_canvas_material.wesl b/Sources/AdaRender/Assets/Shaders/Materials/color_canvas_material.wesl new file mode 100644 index 000000000..d4f9d3223 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/Materials/color_canvas_material.wesl @@ -0,0 +1,19 @@ +import ada_engine::mesh2d::{Mesh2dVertexIn, Mesh2dVertexOut, mesh2dVertex}; + +// WESL port of color_canvas_material.glsl. + +struct ColorCanvasMaterial { + u_Color: vec4f, +} + +@group(0) @binding(0) var material: ColorCanvasMaterial; + +@vertex +fn mesh_vertex(input: Mesh2dVertexIn) -> Mesh2dVertexOut { + return mesh2dVertex(input); +} + +@fragment +fn color_canvas_mesh_fragment(input: Mesh2dVertexOut) -> @location(0) vec4f { + return material.u_Color; +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/baked_diffuse.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/baked_diffuse.wgsl new file mode 100644 index 000000000..303810a62 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/baked_diffuse.wgsl @@ -0,0 +1,125 @@ +// WESL port of Public/BakedDiffuse3D.glsl. All RGB values are diffuse irradiance / pi. +// Features: ADA_LIGHTMAP, ADA_IRRADIANCE_GRID. The IBL sampler and the lightmap parameters come from the caller. + +struct IrradianceGrid3DUniform { + u_GridOriginIntensity: vec4f, + u_GridSpacingNormalBias: vec4f, + u_GridCountsVisibilityBias: vec4f, + u_GridQuality: vec4f, +} + +@if(ADA_LIGHTMAP) @group(0) @binding(46) var u_DiffuseLightmap: texture_2d; +@if(ADA_LIGHTMAP) @group(0) @binding(47) var u_LightmapSampler: sampler; +@if(ADA_IRRADIANCE_GRID) @group(0) @binding(43) var u_ProbeSH: texture_2d; +@if(ADA_IRRADIANCE_GRID) @group(0) @binding(44) var u_ProbeVisibility: texture_2d; +@if(ADA_IRRADIANCE_GRID) @group(0) @binding(48) var u_DynamicProbeSH: texture_2d; +@if(ADA_IRRADIANCE_GRID) @group(0) @binding(45) var irradianceGrid: IrradianceGrid3DUniform; + +@if(ADA_IRRADIANCE_GRID) +fn adaOctEncode(direction: vec3f) -> vec2f { + var d = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 0.000001); + if d.z < 0.0 { + let folded = (vec2f(1.0) - abs(d.yx)) * select(vec2f(-1.0), vec2f(1.0), d.xy >= vec2f(0.0)); + d = vec3f(folded, d.z); + } + return d.xy * 0.5 + vec2f(0.5); +} + +// Probe loads use textureLoad, so no sampler is needed; dynamicOnly skips the baked coefficients. +@if(ADA_IRRADIANCE_GRID) +fn adaProbeSH(address: vec2i, n: vec3f, dynamicOnly: bool) -> vec3f { + var basis = array( + 0.2820947918, 0.4886025119 * n.y, 0.4886025119 * n.z, 0.4886025119 * n.x, + 1.0925484306 * n.x * n.y, 1.0925484306 * n.y * n.z, 0.3153915653 * (3.0 * n.z * n.z - 1.0), + 1.0925484306 * n.x * n.z, 0.5462742153 * (n.x * n.x - n.y * n.y) + ); + var value = vec3f(0.0); + for (var i = 0; i < 9; i++) { + if !dynamicOnly { + value += textureLoad(u_ProbeSH, address + vec2i(i, 0), 0).rgb * basis[i]; + } + if irradianceGrid.u_GridQuality.w > 0.5 { + value += textureLoad(u_DynamicProbeSH, address + vec2i(i, 0), 0).rgb * basis[i]; + } + } + return max(value, vec3f(0.0)); +} + +@if(ADA_IRRADIANCE_GRID) +fn adaIrradianceGrid(world: vec3f, normal: vec3f, dynamicOnly: bool) -> vec4f { + let grid = irradianceGrid; + if grid.u_GridQuality.y < 0.5 { + return vec4f(0.0); + } + let local = (world - grid.u_GridOriginIntensity.xyz) / grid.u_GridSpacingNormalBias.xyz; + let counts = grid.u_GridCountsVisibilityBias.xyz; + if any(local < vec3f(0.0)) || any(local > counts - vec3f(1.0)) { + return vec4f(0.0); + } + let cell = clamp(vec3i(floor(local)), vec3i(0), vec3i(counts) - vec3i(2)); + let blend = clamp(local - vec3f(cell), vec3f(0.0), vec3f(1.0)); + let edge = min(local, counts - vec3f(1.0) - local); + let coverage = clamp(min(edge.x, min(edge.y, edge.z)) * 2.0, 0.0, 1.0); + let biased = world + normal * grid.u_GridSpacingNormalBias.w; + var sum = vec3f(0.0); + var weights = 0.0; + let resolution = i32(grid.u_GridQuality.x); + let rows = i32(counts.y); + for (var z = 0; z < 2; z++) { + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + let probe = cell + vec3i(x, y, z); + let shAddress = vec2i(probe.x * 9, probe.z * rows + probe.y); + let valid = textureLoad(u_ProbeSH, shAddress, 0).a; + if valid < 0.5 { + continue; + } + let position = grid.u_GridOriginIntensity.xyz + vec3f(probe) * grid.u_GridSpacingNormalBias.xyz; + let delta = biased - position; + let distance = length(delta); + let uv = clamp(vec2i(adaOctEncode(delta) * f32(resolution)), vec2i(0), vec2i(resolution - 1)); + let address = vec2i(probe.x, probe.z * rows + probe.y) * resolution + uv; + let moments = textureLoad(u_ProbeVisibility, address, 0).rgb; + let difference = max(distance - moments.x - grid.u_GridCountsVisibilityBias.w, 0.0); + let variance = max(moments.y - moments.x * moments.x, 0.0); + var visibility = select(variance / max(variance + difference * difference, 0.000001), 1.0, difference <= 0.0); + visibility = pow(clamp(visibility, 0.0, 1.0), grid.u_GridQuality.z); + // Reject near-zero weights instead of introducing a light-leaking minimum weight. + visibility = max(visibility - 0.05, 0.0) / 0.95; + let factors = mix(vec3f(1.0) - blend, blend, vec3f(f32(x), f32(y), f32(z))); + let weight = factors.x * factors.y * factors.z * visibility; + sum += adaProbeSH(shAddress, normal, dynamicOnly) * weight; + weights += weight; + } + } + } + // An interior point with no visible valid probe stays dark, rather than falling back to leaking global IBL. + let irradiance = select(vec3f(0.0), sum / weights * grid.u_GridOriginIntensity.w, weights > 0.000001); + return vec4f(irradiance, coverage); +} + +fn adaBakedDiffuse(world: vec3f, normal: vec3f, uv1: vec2f, lightmapScaleOffset: vec4f, bakedParameters: vec4f) -> vec4f { + var lightmap = vec4f(0.0); + @if(ADA_LIGHTMAP) { + let size = vec2f(textureDimensions(u_DiffuseLightmap, 0)); + let uv = clamp(uv1 * lightmapScaleOffset.xy + lightmapScaleOffset.zw, vec2f(0.5) / size, vec2f(1.0) - vec2f(0.5) / size); + lightmap = textureSampleLevel(u_DiffuseLightmap, u_LightmapSampler, uv, 0.0); + lightmap = vec4f(lightmap.rgb * bakedParameters.x, clamp(lightmap.a, 0.0, 1.0)); + if lightmap.a >= 0.999 { + @if(ADA_IRRADIANCE_GRID) { + if irradianceGrid.u_GridQuality.w > 0.5 { + let dynamicLight = adaIrradianceGrid(world, normal, true); + lightmap = vec4f(lightmap.rgb + dynamicLight.rgb * dynamicLight.a, lightmap.a); + } + } + return lightmap; + } + } + @if(ADA_IRRADIANCE_GRID) { + let grid = adaIrradianceGrid(world, normal, false); + let coverage = lightmap.a + (1.0 - lightmap.a) * grid.a; + let value = lightmap.rgb * lightmap.a + grid.rgb * (1.0 - lightmap.a) * grid.a; + return vec4f(value / max(coverage, 0.000001), coverage); + } + return lightmap; +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/color_grading.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/color_grading.wgsl new file mode 100644 index 000000000..2a89a952e --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/color_grading.wgsl @@ -0,0 +1,44 @@ +// WESL port of Public/ColorGrading.glsl. The LUT texture and sampler are parameters instead of includer globals. +// The LUT is a strip of N tiles (N x N pixels each): red along x, green down rows, blue selects the tile. + +// grading: x saturation, y contrast, z vignette intensity, w vignette radius; vignette.x is smoothness. +fn adaGradeLinear(input: vec3f, grading: vec4f, vignette: vec4f, uv: vec2f) -> vec3f { + var color = input; + if grading.y != 1.0 { + let pivot = 0.18; + color = pivot * pow(max(color, vec3f(0.0)) / pivot, vec3f(grading.y)); + } + if grading.x != 1.0 { + let luma = dot(color, vec3f(0.2126, 0.7152, 0.0722)); + color = max(mix(vec3f(luma), color, grading.x), vec3f(0.0)); + } + if grading.z > 0.0 { + let corner = length((uv - vec2f(0.5)) * 2.0) / 1.41421356; + color *= 1.0 - grading.z * smoothstep(grading.w, grading.w + max(vignette.x, 0.01), corner); + } + return color; +} + +fn adaSampleLutTile(lut: texture_2d, lutSampler: sampler, color: vec3f, tile: f32, size: f32) -> vec3f { + let texel = vec2f(1.0 / (size * size), 1.0 / size); + let inTile = color.rg * (size - 1.0) + vec2f(0.5); + return textureSampleLevel(lut, lutSampler, vec2f(tile * size + inTile.x, inTile.y) * texel, 0.0).rgb; +} + +// Display-referred color through the LUT, blended by `intensity` (0 returns the input unchanged). +fn adaApplyLut(lut: texture_2d, lutSampler: sampler, color: vec3f, intensity: f32) -> vec3f { + if intensity <= 0.0 { + return color; + } + let size = f32(textureDimensions(lut, 0).y); + let clamped = clamp(color, vec3f(0.0), vec3f(1.0)); + let blue = clamped.b * (size - 1.0); + let lower = floor(blue); + let upper = min(lower + 1.0, size - 1.0); + let graded = mix( + adaSampleLutTile(lut, lutSampler, clamped, lower, size), + adaSampleLutTile(lut, lutSampler, clamped, upper, size), + blue - lower + ); + return mix(color, graded, intensity); +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/distance_field.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/distance_field.wgsl new file mode 100644 index 000000000..c88814f29 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/distance_field.wgsl @@ -0,0 +1,102 @@ +// WESL port of Public/DistanceField3D.glsl: world-space SDF shadow queries over R32F node samples in tiled Z slices. +// The caller supplies its nearest sampler (the GLSL path reused u_DirectionalShadowSampler). + +struct DistanceField3DUniform { + u_SDFMinimumVoxel: vec4f, // world minimum.xyz, cubic voxel size + u_SDFCountsDistance: vec4f, // sample counts.xyz, maximum shadow ray distance + u_SDFTrace: vec4f, // maximum steps, normal bias, cone tangent, 0 off / 1 shadows / 2 debug +} + +@group(0) @binding(49) var u_DistanceFieldTexture: texture_2d; +@group(0) @binding(30) var distanceField: DistanceField3DUniform; + +fn adaDistanceFieldNode(p: vec3i) -> f32 { + let nx = i32(distanceField.u_SDFCountsDistance.x); + let ny = i32(distanceField.u_SDFCountsDistance.y); + let columns = max(i32(textureDimensions(u_DistanceFieldTexture, 0).x) / nx, 1); + let address = vec2i(p.x + (p.z % columns) * nx, p.y + (p.z / columns) * ny); + return textureLoad(u_DistanceFieldTexture, address, 0).r; +} + +fn adaDistanceFieldSample(world: vec3f) -> f32 { + let counts = distanceField.u_SDFCountsDistance.xyz; + let p = clamp( + (world - distanceField.u_SDFMinimumVoxel.xyz) / distanceField.u_SDFMinimumVoxel.w, + vec3f(0.0), + counts - vec3f(1.0) + ); + let cell = min(vec3i(floor(p)), vec3i(counts) - vec3i(2)); + let f = p - vec3f(cell); + let a = mix(adaDistanceFieldNode(cell), adaDistanceFieldNode(cell + vec3i(1, 0, 0)), f.x); + let b = mix(adaDistanceFieldNode(cell + vec3i(0, 1, 0)), adaDistanceFieldNode(cell + vec3i(1, 1, 0)), f.x); + let c = mix(adaDistanceFieldNode(cell + vec3i(0, 0, 1)), adaDistanceFieldNode(cell + vec3i(1, 0, 1)), f.x); + let d = mix(adaDistanceFieldNode(cell + vec3i(0, 1, 1)), adaDistanceFieldNode(cell + vec3i(1, 1, 1)), f.x); + return mix(mix(a, b, f.y), mix(c, d, f.y), f.z); +} + +struct DistanceFieldInterval { + valid: bool, + enter: f32, + leave: f32, +} + +// Clip the trace to the known field. Geometry outside that bounded domain is left to ordinary shadow maps. +fn adaDistanceFieldInterval(origin: vec3f, direction: vec3f, limit: f32) -> DistanceFieldInterval { + let minimum = distanceField.u_SDFMinimumVoxel.xyz; + let maximum = minimum + (distanceField.u_SDFCountsDistance.xyz - vec3f(1.0)) * distanceField.u_SDFMinimumVoxel.w; + var enter = 0.0; + var leave = limit; + for (var axis = 0; axis < 3; axis++) { + if abs(direction[axis]) < 0.000001 { + if origin[axis] < minimum[axis] || origin[axis] > maximum[axis] { + return DistanceFieldInterval(false, 0.0, 0.0); + } + } else { + let a = (minimum[axis] - origin[axis]) / direction[axis]; + let b = (maximum[axis] - origin[axis]) / direction[axis]; + enter = max(enter, min(a, b)); + leave = min(leave, max(a, b)); + if leave < enter { + return DistanceFieldInterval(false, 0.0, 0.0); + } + } + } + return DistanceFieldInterval(leave > enter, enter, leave); +} + +fn adaDistanceFieldVisibility(world: vec3f, normal: vec3f, direction: vec3f, maximumDistance: f32) -> f32 { + let trace = distanceField.u_SDFTrace; + if trace.w < 0.5 { + return 1.0; + } + let voxel = distanceField.u_SDFMinimumVoxel.w; + // The weighted interpolation error of a 1-Lipschitz distance function is bounded by half a voxel diagonal. + let margin = voxel * 0.866025404; + let epsilon = voxel * 0.025; + let origin = world + normal * max(trace.y, margin + epsilon * 2.0); + let interval = adaDistanceFieldInterval(origin, direction, min(maximumDistance, distanceField.u_SDFCountsDistance.w)); + if !interval.valid { + return 1.0; + } + var travel = interval.enter; + var visibility = 1.0; + let steps = i32(trace.x); + for (var step = 0; step < 128; step++) { + if travel >= interval.leave { + return clamp(visibility, 0.0, 1.0); + } + if step >= steps { + break; + } + let distance = adaDistanceFieldSample(origin + direction * travel) - margin; + if distance <= epsilon { + return 0.0; + } + if trace.z > 0.00001 { + visibility = min(visibility, distance / max(travel * trace.z, epsilon)); + } + travel += max(distance * 0.8, epsilon); + } + // An unresolved trace is not evidence of an unobstructed light path. + return select(0.0, clamp(visibility, 0.0, 1.0), travel >= interval.leave); +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/mesh2d.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/mesh2d.wgsl new file mode 100644 index 000000000..fb86d29cd --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/mesh2d.wgsl @@ -0,0 +1,41 @@ +import package::view::view; + +// Shared 2D mesh vertex stage. Vertex layout selects attributes through the VERTEX_* features, +// matching the defines the canvas materials pass for the mesh's vertex descriptor. + +struct Mesh2dUniform { + u_MeshModel: mat4x4f, + u_MeshInverseTransposeModel: mat4x4f, +} + +@group(0) @binding(3) var mesh2d: Mesh2dUniform; + +struct Mesh2dVertexIn { + @builtin(vertex_index) vertexIndex: u32, + @if(VERTEX_POSITIONS) @location(0) position: vec3f, + @if(VERTEX_NORMALS) @location(1) normal: vec3f, + @if(VERTEX_UVS) @location(2) uv: vec2f, + @if(VERTEX_COLORS) @location(3) vertexColor: vec4f, +} + +struct Mesh2dVertexOut { + @builtin(position) position: vec4f, + @location(0) worldPosition: vec4f, + @location(1) worldNormal: vec3f, + @location(2) uv: vec2f, + @location(3) vertexColor: vec4f, +} + +fn mesh2dVertex(input: Mesh2dVertexIn) -> Mesh2dVertexOut { + var worldPosition = vec4f(0.0); + var worldNormal = vec3f(0.0); + var uv = vec2f(0.0); + var vertexColor = vec4f(0.0); + let normalTransform = mesh2d.u_MeshInverseTransposeModel; + let normalMatrix = mat3x3f(normalTransform[0].xyz, normalTransform[1].xyz, normalTransform[2].xyz); + @if(VERTEX_POSITIONS) worldPosition = mesh2d.u_MeshModel * vec4f(input.position, 1.0); + @if(VERTEX_NORMALS) worldNormal = normalMatrix * input.normal; + @if(VERTEX_UVS) uv = input.uv; + @if(VERTEX_COLORS) vertexColor = input.vertexColor; + return Mesh2dVertexOut(view.viewProjection * worldPosition, worldPosition, worldNormal, uv, vertexColor); +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/morph.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/morph.wgsl new file mode 100644 index 000000000..474f0f386 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/morph.wgsl @@ -0,0 +1,74 @@ +// WESL port of Public/Morph.glsl. Morph targets (glTF blend shapes) applied before skinning. +// Declared only for draws of morphed meshes, which enable the ADA_MORPH feature. + +struct Morph3DUniform { + u_MorphCounts: vec4f, // x active targets, y previous frame's, z vertices per target, w attributes per vertex + u_MorphFlags: vec4f, // x bit 0: normal deltas, bit 1: tangent deltas + u_MorphCurrent: array, // x target index, y weight + u_MorphPrevious: array, // previous frame's, for motion vectors +} + +struct MorphedVertex { + position: vec3f, + normal: vec3f, + tangent: vec3f, +} + +@if(ADA_MORPH) @group(0) @binding(21) var morph: Morph3DUniform; +@if(ADA_MORPH) @group(0) @binding(40) var u_MorphDeltas: texture_2d; + +const MORPH_TEXTURE_WIDTH: i32 = 2048; + +@if(ADA_MORPH) +fn morphDelta(vertexIndex: u32, morphTarget: i32, channel: i32) -> vec3f { + let texel = (morphTarget * i32(morph.u_MorphCounts.z) + i32(vertexIndex)) * i32(morph.u_MorphCounts.w) + channel; + return textureLoad(u_MorphDeltas, vec2i(texel % MORPH_TEXTURE_WIDTH, texel / MORPH_TEXTURE_WIDTH), 0).xyz; +} + +@if(ADA_MORPH) +fn applyMorphPosition(vertexIndex: u32, input: vec3f) -> vec3f { + var position = input; + for (var i = 0; i < 8; i++) { + if i >= i32(morph.u_MorphCounts.x) { + break; + } + position += morphDelta(vertexIndex, i32(morph.u_MorphCurrent[i].x), 0) * morph.u_MorphCurrent[i].y; + } + return position; +} + +@if(ADA_MORPH) +fn previousMorphedPosition(vertexIndex: u32, input: vec3f) -> vec3f { + var position = input; + for (var i = 0; i < 8; i++) { + if i >= i32(morph.u_MorphCounts.y) { + break; + } + position += morphDelta(vertexIndex, i32(morph.u_MorphPrevious[i].x), 0) * morph.u_MorphPrevious[i].y; + } + return position; +} + +@if(ADA_MORPH) +fn applyMorph(vertexIndex: u32, position: vec3f, normal: vec3f, tangent: vec3f) -> MorphedVertex { + var result = MorphedVertex(position, normal, tangent); + let flags = i32(morph.u_MorphFlags.x); + let hasNormals = (flags & 1) != 0; + let hasTangents = (flags & 2) != 0; + let tangentChannel = select(1, 2, hasNormals); + for (var i = 0; i < 8; i++) { + if i >= i32(morph.u_MorphCounts.x) { + break; + } + let morphTarget = i32(morph.u_MorphCurrent[i].x); + let weight = morph.u_MorphCurrent[i].y; + result.position += morphDelta(vertexIndex, morphTarget, 0) * weight; + if hasNormals { + result.normal += morphDelta(vertexIndex, morphTarget, 1) * weight; + } + if hasTangents { + result.tangent += morphDelta(vertexIndex, morphTarget, tangentChannel) * weight; + } + } + return result; +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/shadow_pcf.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/shadow_pcf.wgsl new file mode 100644 index 000000000..a99c2a615 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/shadow_pcf.wgsl @@ -0,0 +1,68 @@ +// WESL port of Public/ShadowPCF.glsl. Filter comparison results, never depths. +// Read the nearest texel directly so 32-bit shadow atlases do not require filterable float textures. + +fn adaNearestShadowDepth(shadowTexture: texture_2d, size: vec2i, uv: vec2f) -> f32 { + let pixel = clamp(vec2i(uv * vec2f(size)), vec2i(0), size - vec2i(1)); + return textureLoad(shadowTexture, pixel, 0).r; +} + +// 3x3 PCF reconstructed continuously across texels with 4x4 weighted comparisons. +fn adaDirectionalShadowPCF( + shadowTexture: texture_2d, + uv: vec2f, texelSize: vec2f, minimum: vec2f, maximum: vec2f, receiverDepth: f32 +) -> f32 { + let texelPosition = uv / texelSize - vec2f(0.5); + let base = floor(texelPosition); + let fraction = fract(texelPosition); + let size = vec2i(textureDimensions(shadowTexture)); + var visibility = 0.0; + for (var y = -1; y <= 2; y++) { + let wy = select(select(1.0, fraction.y, y == 2), 1.0 - fraction.y, y == -1); + for (var x = -1; x <= 2; x++) { + let wx = select(select(1.0, fraction.x, x == 2), 1.0 - fraction.x, x == -1); + let sampleUV = clamp((base + vec2f(f32(x), f32(y)) + vec2f(0.5)) * texelSize, minimum, maximum); + let storedDepth = adaNearestShadowDepth(shadowTexture, size, sampleUV); + visibility += wx * wy * select(0.0, 1.0, receiverDepth <= storedDepth); + } + } + return visibility / 9.0; +} + +// Bounded PCSS: blocker search, then a footprint that grows with receiver/blocker separation. +fn adaDirectionalShadowPCSS( + shadowTexture: texture_2d, + uv: vec2f, texel: vec2f, minimum: vec2f, maximum: vec2f, + receiverDepth: f32, shadowMatrix: mat4x4f, settings: vec4f, cascadeCount: f32 +) -> f32 { + let size = vec2i(textureDimensions(shadowTexture)); + var blockers = 0.0; + var depthSum = 0.0; + for (var i = 0; i < 16; i++) { + let angle = f32(i) * 2.39996323; + let disk = vec2f(cos(angle), sin(angle)) * sqrt((f32(i) + 0.5) / 16.0); + let sampleUV = clamp(uv + disk * texel * settings.w, minimum, maximum); + let depth = adaNearestShadowDepth(shadowTexture, size, sampleUV); + if depth < receiverDepth { + blockers += 1.0; + depthSum += depth; + } + } + if blockers < 0.5 { + return 1.0; + } + let depthRow = vec3f(shadowMatrix[0].z, shadowMatrix[1].z, shadowMatrix[2].z); + let separation = max(receiverDepth - depthSum / blockers, 0.0) / max(length(depthRow), 0.000001); + let worldToUV = 0.5 * vec2f( + length(vec3f(shadowMatrix[0].x, shadowMatrix[1].x, shadowMatrix[2].x)) / cascadeCount, + length(vec3f(shadowMatrix[0].y, shadowMatrix[1].y, shadowMatrix[2].y)) + ); + let radius = clamp(separation * settings.y * worldToUV, texel, texel * settings.z); + var visibility = 0.0; + for (var i = 0; i < 24; i++) { + let angle = f32(i) * 2.39996323; + let disk = vec2f(cos(angle), sin(angle)) * sqrt((f32(i) + 0.5) / 24.0); + let depth = adaNearestShadowDepth(shadowTexture, size, clamp(uv + disk * radius, minimum, maximum)); + visibility += select(0.0, 1.0, receiverDepth <= depth); + } + return visibility / 24.0; +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/tonemapping.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/tonemapping.wgsl new file mode 100644 index 000000000..5b112bc9e --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/tonemapping.wgsl @@ -0,0 +1,60 @@ +// WESL port of Public/Tonemapping.glsl. `mode` matches ToneMapper3D.shaderMode: +// 0 ACES filmic fit (Narkowicz), 1 Khronos PBR Neutral, 2 None (clip). + +fn adaLinearToSrgb(input: vec3f) -> vec3f { + let value = max(input, vec3f(0.0)); + return mix(value * 12.92, 1.055 * pow(value, vec3f(1.0 / 2.4)) - 0.055, step(vec3f(0.0031308), value)); +} + +fn adaAcesFit(value: vec3f) -> vec3f { + let a = 2.51; + let b = 0.03; + let c = 2.43; + let d = 0.59; + let e = 0.14; + return clamp((value * (a * value + b)) / (value * (c * value + d) + e), vec3f(0.0), vec3f(1.0)); +} + +// Khronos PBR Neutral Tone Mapper (https://github.com/KhronosGroup/ToneMapping), Apache-2.0. +fn adaNeutral(input: vec3f) -> vec3f { + let startCompression = 0.8 - 0.04; + let desaturation = 0.15; + let x = min(input.r, min(input.g, input.b)); + let offset = select(0.04, x - 6.25 * x * x, x < 0.08); + var color = input - vec3f(offset); + let peak = max(color.r, max(color.g, color.b)); + if peak < startCompression { + return color; + } + let d = 1.0 - startCompression; + let newPeak = 1.0 - d * d / (peak + d - startCompression); + color *= newPeak / peak; + let g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0); + return mix(color, newPeak * vec3f(1.0), g); +} + +fn adaToneMap(color: vec3f, mode: f32) -> vec3f { + if mode > 1.5 { + return clamp(color, vec3f(0.0), vec3f(1.0)); + } + if mode > 0.5 { + return clamp(adaNeutral(max(color, vec3f(0.0))), vec3f(0.0), vec3f(1.0)); + } + return adaAcesFit(color); +} + +fn adaOutputTransform(linearColor: vec3f, mode: f32) -> vec3f { + return adaLinearToSrgb(adaToneMap(linearColor, mode)); +} + +// Stochastic rounding to 8-bit display codes with a screen-stable hash (no animated grain). +fn adaDitherDisplay(encodedColor: vec3f, pixel: vec2f) -> vec3f { + let coordinate = vec2u(floor(pixel)); + var hash = (coordinate.x * 0x1f123bb5u) ^ (coordinate.y * 0x5f356495u); + hash = (hash ^ (hash >> 16u)) * 0x7feb352du; + hash = (hash ^ (hash >> 15u)) * 0x846ca68bu; + hash ^= hash >> 16u; + let threshold = (f32(hash & 65535u) + 0.5) / 65536.0; + let codes = floor(clamp(encodedColor, vec3f(0.0), vec3f(1.0)) * 255.0 + vec3f(threshold)); + return clamp(codes, vec3f(0.0), vec3f(255.0)) / 255.0; +} diff --git a/Sources/AdaRender/Assets/Shaders/WESL/view.wgsl b/Sources/AdaRender/Assets/Shaders/WESL/view.wgsl new file mode 100644 index 000000000..f1e719788 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/WESL/view.wgsl @@ -0,0 +1,11 @@ +struct ViewUniform { + projection: mat4x4f, + viewProjection: mat4x4f, + viewMatrix: mat4x4f, +} + +@group(0) @binding(2) var view: ViewUniform; + +fn projectPosition(position: vec4f) -> vec4f { + return view.viewProjection * position; +} diff --git a/Sources/AdaRender/Assets/Shaders/mesh2d/mesh2d.wesl b/Sources/AdaRender/Assets/Shaders/mesh2d/mesh2d.wesl new file mode 100644 index 000000000..580761b07 --- /dev/null +++ b/Sources/AdaRender/Assets/Shaders/mesh2d/mesh2d.wesl @@ -0,0 +1,17 @@ +import ada_engine::mesh2d::{Mesh2dVertexIn, Mesh2dVertexOut, mesh2dVertex}; + +// WESL port of mesh2d.glsl: the default canvas mesh material. + +@vertex +fn mesh_vertex(input: Mesh2dVertexIn) -> Mesh2dVertexOut { + return mesh2dVertex(input); +} + +@fragment +fn mesh_fragment(input: Mesh2dVertexOut) -> @location(0) vec4f { + var color = vec4f(1.0, 0.0, 1.0, 1.0); + @if(VERTEX_COLORS) color = input.vertexColor; + // To avoid depth write. + @if(VERTEX_COLORS) if color.a == 0.0 { discard; } + return color; +} diff --git a/Sources/AdaRender/Backends/RenderBackend.swift b/Sources/AdaRender/Backends/RenderBackend.swift index 40bb7015d..b11e63fe2 100644 --- a/Sources/AdaRender/Backends/RenderBackend.swift +++ b/Sources/AdaRender/Backends/RenderBackend.swift @@ -17,6 +17,8 @@ public enum TriangleFillMode { public enum RenderBackendType: String, Sendable { case metal case webgpu + /// Native SAGA runtime, using Metal on Apple platforms or Vulkan on other native targets. + case saga case headless } @@ -51,6 +53,8 @@ protocol RenderBackend: AnyObject, Sendable { /// The GPU device instance resposible for rendering and computing. public protocol RenderDevice: AnyObject, Sendable { + /// Whether 32-bit float textures support linear sampling on this device. + var supportsFloat32TextureFiltering: Bool { get } /// Whether vertex layouts can advance a buffer once per instance. /// Devices without this capability retain expanded vertex geometry. var supportsInstancedVertexInputs: Bool { get } @@ -151,6 +155,7 @@ public protocol Drawable: AnyObject, Sendable { } extension RenderDevice { + public var supportsFloat32TextureFiltering: Bool { true } public var supportsInstancedVertexInputs: Bool { false } public func readImage(from texture: Texture) async throws -> Image? { getImage(from: texture) diff --git a/Sources/AdaRender/Backends/SAGA/JSBrowserTransport.swift b/Sources/AdaRender/Backends/SAGA/JSBrowserTransport.swift new file mode 100644 index 000000000..d79a4db1e --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/JSBrowserTransport.swift @@ -0,0 +1,38 @@ +// +// JSBrowserTransport.swift +// AdaEngine +// + +#if SAGA_WEBGPU && canImport(SAGAWebHost) && canImport(JavaScriptKit) + import JavaScriptKit + import SAGATypes + import SAGAWebHost + + /// Sends SAGA opcode packets to the page's JavaScript WebGPU host (`host.handle(Uint8Array)`). + struct JSBrowserTransport: BrowserTransport, @unchecked Sendable { + let host: JSObject + + func send(_ bytes: [UInt8]) async throws -> [UInt8] { + let request = JSTypedArray(bytes) + guard let pending = host.handle?(request).object, let promise = JSPromise(pending) else { + throw GPUError.backendFailure("SAGA browser host returned no promise") + } + let reply: JSValue = try await withCheckedThrowingContinuation { continuation in + _ = promise.then( + success: { value in + continuation.resume(returning: value) + return JSValue.undefined + }, + failure: { error in + continuation.resume(throwing: GPUError.backendFailure(error.description)) + return JSValue.undefined + } + ) + } + guard let array = JSTypedArray(from: reply) else { + throw GPUError.backendFailure("SAGA browser host returned a non-byte reply") + } + return array.withUnsafeBytes { unsafe Array($0) } + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/OrderedGPUWorkQueue.swift b/Sources/AdaRender/Backends/SAGA/OrderedGPUWorkQueue.swift new file mode 100644 index 000000000..ffa041d02 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/OrderedGPUWorkQueue.swift @@ -0,0 +1,154 @@ +// +// OrderedGPUWorkQueue.swift +// AdaEngine +// +// Bridges AdaRender's synchronous resource API to SAGA's asynchronous device. +// + +import Foundation +import Synchronization + +/// A write-once cell for a resource created by queued work. +/// +/// Work on one ``OrderedGPUWorkQueue`` runs in order, so work enqueued after the +/// creating work can read the slot synchronously. +final class ResourceSlot: Sendable { + enum SlotError: Error { + case notCreated + } + + private let storage = Mutex(nil) + + func fulfill(_ value: Value) { + storage.withLock { $0 = value } + } + + func get() throws -> Value { + guard let value = storage.withLock({ $0 }) else { + throw SlotError.notCreated + } + return value + } +} + +/// Runs enqueued work strictly in submission order against a context created asynchronously. +/// +/// `enqueue` never blocks, so render code can create resources and record uploads +/// synchronously while the SAGA device (an actor) is reached in the background. +/// A failure in one item is reported and does not stop later items. A failure to +/// create the context is reported once. Device work is then skipped, and cleanup still runs. +final class OrderedGPUWorkQueue: Sendable { + typealias Work = @Sendable (Context) async throws -> Void + typealias Cleanup = @Sendable () async throws -> Void + + private enum Job: Sendable { + case device(Work, onUnavailable: Cleanup?) + case cleanup(Cleanup) + } + + /// `ADAENGINE_SAGA_TRACE=1` writes each job's origin to stderr as it starts and ends, to find a stalled job. + private static var traces: Bool { ProcessInfo.processInfo.environment["ADAENGINE_SAGA_TRACE"] == "1" } + private static func trace(_ message: String) { + FileHandle.standardError.write(Data(("[SAGA queue] " + message + "\n").utf8)) + } + + private struct State { + var continuation: AsyncStream<(Job, String)>.Continuation? + var pending = 0 + var idleWaiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + private let onError: @Sendable (any Error) -> Void + + init( + context: @escaping @Sendable () async throws -> Context, + onError: @escaping @Sendable (any Error) -> Void + ) { + self.onError = onError + let (stream, continuation) = AsyncStream<(Job, String)>.makeStream() + state.withLock { $0.continuation = continuation } + Task { [weak self] in + var resolved: Context? + var contextFailed = false + let tracing = Self.traces + var sequence = 0 + for await (job, origin) in stream { + sequence += 1 + if tracing { Self.trace("begin \(sequence) \(origin)") } + do { + switch job { + case let .device(work, onUnavailable): + if resolved == nil, !contextFailed { + do { + resolved = try await context() + } catch { + contextFailed = true + self?.onError(error) + } + } + if let resolved { + try await work(resolved) + } else { + try await onUnavailable?() + } + case .cleanup(let cleanup): + try await cleanup() + } + } catch { + if tracing { Self.trace("FAILED \(sequence) \(origin): \(error)") } + self?.onError(error) + } + if tracing { Self.trace("end \(sequence)") } + self?.finishOne() + } + } + } + + deinit { + state.withLock { $0.continuation?.finish() } + } + + func enqueue(onUnavailable: Cleanup? = nil, _ work: @escaping Work, file: String = #fileID, line: Int = #line) { + enqueue(.device(work, onUnavailable: onUnavailable), origin: "\(file):\(line)") + } + + /// Runs in queue order without creating or requiring a device. + func enqueueCleanup(_ cleanup: @escaping Cleanup, file: String = #fileID, line: Int = #line) { + enqueue(.cleanup(cleanup), origin: "\(file):\(line)") + } + + private func enqueue(_ job: Job, origin: String) { + let continuation = state.withLock { state -> AsyncStream<(Job, String)>.Continuation? in + state.pending += 1 + return state.continuation + } + continuation?.yield((job, origin)) + } + + /// Suspends until all work enqueued before the call has finished. + func drain() async { + await withCheckedContinuation { waiter in + let resumeNow = state.withLock { state -> Bool in + if state.pending == 0 { + return true + } + state.idleWaiters.append(waiter) + return false + } + if resumeNow { waiter.resume() } + } + } + + private func finishOne() { + let waiters = state.withLock { state -> [CheckedContinuation] in + state.pending -= 1 + guard state.pending == 0 else { + return [] + } + defer { state.idleWaiters.removeAll() } + return state.idleWaiters + } + for waiter in waiters { waiter.resume() } + } +} diff --git a/Sources/AdaRender/Backends/SAGA/SAGABuffer.swift b/Sources/AdaRender/Backends/SAGA/SAGABuffer.swift new file mode 100644 index 000000000..dad34ace0 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGABuffer.swift @@ -0,0 +1,128 @@ +// +// SAGABuffer.swift +// AdaEngine +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import Foundation + import SAGA + import SAGACore + import SAGATypes + + extension ResourceOptions { + /// Every AdaRender buffer may be written by the CPU and read by any pipeline stage. + var sagaUsage: BufferUsage { + [.copySource, .copyDestination, .storage, .uniform, .vertex, .index, .indirect] + } + } + + /// A GPU buffer backed by a SAGA resource. + /// + /// The buffer keeps a CPU shadow so ``contents()`` can hand out a writable pointer that is uploaded by ``unmap()``. + @_spi(Internal) + public class SAGABuffer: Buffer, @unchecked Sendable { + let context: SAGAContext + let slot: ResourceSlot + public let length: Int + public var label: String? + @unsafe private let shadow: UnsafeMutableRawPointer + + init(context: SAGAContext, label: String?, length: Int, usage: BufferUsage) { + self.context = context + self.label = label + self.length = length + unsafe self.shadow = UnsafeMutableRawPointer.allocate(byteCount: max(length, 1), alignment: 16) + unsafe self.shadow.initializeMemory(as: UInt8.self, repeating: 0, count: max(length, 1)) + self.slot = context.createBuffer( + BufferDescriptor(label: label ?? "", size: UInt64(length), usage: usage) + ) + } + + deinit { + unsafe shadow.deallocate() + let slot = self.slot + context.queue.enqueue { device in + if let id = try? slot.get() { + try await device.destroyBuffer(id) + } + } + } + + public func contents() -> UnsafeMutableRawPointer { + unsafe shadow + } + + public func unmap() { + upload(offset: 0, byteCount: length) + } + + public func setData(_ bytes: UnsafeMutableRawPointer, byteCount: Int, offset: Int) { + guard byteCount > 0, offset >= 0, offset + byteCount <= length else { return } + unsafe (shadow + offset).copyMemory(from: bytes, byteCount: byteCount) + upload(offset: offset, byteCount: byteCount) + } + + public func readData() async throws -> Data { + let source = self.slot + let length = self.length + let staging = context.createBuffer( + BufferDescriptor(label: "readback", size: UInt64((length + 3) & ~3), usage: [.copyDestination, .mapRead]) + ) + return try await withCheckedThrowingContinuation { continuation in + context.queue.enqueue { device in + do { + let src = try source.get() + let dst = try staging.get() + _ = try await device.submitCommands([.copy(source: src, destination: dst)]).wait() + let mapping = try await device.mapBuffer(dst, mode: .read) + let bytes = try await device.readMappedBytes(mapping) + try await device.unmapBuffer(dst) + try await device.destroyBuffer(dst) + continuation.resume(returning: Data(bytes.prefix(length))) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func upload(offset: Int, byteCount: Int) { + let bytes = unsafe [UInt8](UnsafeRawBufferPointer(start: shadow + offset, count: byteCount)) + let slot = self.slot + context.queue.enqueue { device in + _ = try await device.writeBuffer(try slot.get(), offset: UInt64(offset), bytes: bytes) + } + } + } + + @_spi(Internal) + public final class SAGAUniformBuffer: SAGABuffer, UniformBuffer, @unchecked Sendable { + public let binding: Int + + init(context: SAGAContext, length: Int, binding: Int) { + self.binding = binding + // WebGPU requires uniform buffers aligned to 16 bytes. + super.init(context: context, label: nil, length: (length + 15) & ~15, usage: ResourceOptions.storageShared.sagaUsage) + } + } + + @_spi(Internal) + public final class SAGAVertexBuffer: SAGABuffer, VertexBuffer, @unchecked Sendable { + public let binding: Int + + init(context: SAGAContext, label: String?, length: Int, binding: Int) { + self.binding = binding + super.init(context: context, label: label, length: length, usage: ResourceOptions.storageShared.sagaUsage) + } + } + + @_spi(Internal) + public final class SAGAIndexBuffer: SAGABuffer, IndexBuffer, @unchecked Sendable { + public let indexFormat: IndexBufferFormat + + init(context: SAGAContext, label: String?, format: IndexBufferFormat, length: Int) { + self.indexFormat = format + super.init(context: context, label: label, length: (length + 3) & ~3, usage: ResourceOptions.storageShared.sagaUsage) + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGACommandBuffer.swift b/Sources/AdaRender/Backends/SAGA/SAGACommandBuffer.swift new file mode 100644 index 000000000..656168400 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGACommandBuffer.swift @@ -0,0 +1,605 @@ +// +// SAGACommandBuffer.swift +// AdaEngine +// +// Records AdaRender's stateful encoders as SAGA command lists. +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import AdaUtils + import Foundation + import Math + import SAGA + import SAGACore + import SAGATypes + import Synchronization + + /// Builds one SAGA command when the device queue runs, after earlier resource work resolved its slots. + typealias SAGACommandBuilder = @Sendable () throws -> BufferCommand + + final class SAGACommandQueue: CommandQueue { + let context: SAGAContext + + init(context: SAGAContext) { + self.context = context + } + + func makeCommandBuffer() -> CommandBuffer { + SAGACommandBuffer(context: context) + } + } + + final class SAGACommandBuffer: CommandBuffer, @unchecked Sendable { + var label: String? + let context: SAGAContext + private var builders: [SAGACommandBuilder] = [] + private var completedHandlers: [@Sendable () -> Void] = [] + private var timingHandlers: [@Sendable (Double?) -> Void] = [] + + init(context: SAGAContext) { + self.context = context + } + + func append(_ builder: @escaping SAGACommandBuilder) { + builders.append(builder) + } + + func beginRenderPass(_ desc: RenderPassDescriptor) -> RenderCommandEncoder { + SAGARenderCommandEncoder(buffer: self, descriptor: desc) + } + + func beginBlitPass(_: BlitPassDescriptor) -> BlitCommandEncoder { + SAGABlitCommandEncoder(buffer: self) + } + + func commit() { + let builders = self.builders + self.builders = [] + let completed = completedHandlers + let timing = timingHandlers + let hasCompletionHandlers = !completed.isEmpty || !timing.isEmpty + let debugErrors = context.debugErrors + completedHandlers = [] + timingHandlers = [] + let notifyCompletion: @Sendable () -> Void = { + timing.forEach { $0(nil) } + completed.forEach { $0() } + } + context.queue.enqueue(onUnavailable: hasCompletionHandlers ? notifyCompletion : nil) { device in + defer { notifyCompletion() } + guard !builders.isEmpty else { + if hasCompletionHandlers { + // Even an empty frame fence must retire after earlier device work. + let fence = try await device.submitResourceUse([]) + try await fence.wait() + } + return + } + let commands = try builders.map { try $0() } + let ticket: CommandTicket + do { + ticket = try await device.submitCommands(commands) + } catch { + if debugErrors { + print("[SAGA] submit failed with \(error): \(commands.count) commands") + for command in commands.prefix(6) { print("[SAGA] \(String(String(describing: command).prefix(700)))") } + } + throw error + } + if hasCompletionHandlers || debugErrors { + try await ticket.wait() + } + if debugErrors { + for error in await device.takeUncapturedErrors() { + print("[SAGA] device error: \(error)") + } + } + } + } + + func addCompletedHandler(_ handler: @escaping @Sendable () -> Void) { + completedHandlers.append(handler) + } + + func addCompletedTimingHandler(_ handler: @escaping @Sendable (Double?) -> Void) { + timingHandlers.append(handler) + } + } + + // MARK: - Render encoder + + /// A pass attachment resolved when the command list is built. + /// + /// SAGA turns every draw into its own render pass, so attachments always store: a later draw of the same + /// AdaRender pass loads them, and a discarded store would leave that draw with undefined contents. + private struct PassColor: Sendable { + let slot: ResourceSlot + let clear: RenderClearColor? + let store: RenderStoreAction + let resolve: ResourceSlot? + let format: TextureFormat + /// The attachments' owners. A texture destroys its GPU resource on deinit, which must not run before the + /// pass that draws into it was built. + let owners: [SAGAGPUTexture] + } + + private struct PassDepth: Sendable { + let slot: ResourceSlot + let depthClear: Double? + let depthStore: RenderStoreAction + let stencilClear: UInt32? + let stencilStore: RenderStoreAction + let hasStencil: Bool + let format: TextureFormat + let owner: SAGAGPUTexture + } + + private struct BufferBinding { + let buffer: SAGABuffer + let offset: Int + let size: Int + } + + final class SAGARenderCommandEncoder: RenderCommandEncoder, @unchecked Sendable { + private let buffer: SAGACommandBuffer + private let colors: [PassColor] + private let depth: PassDepth? + private var drewInPass = false + + private var pipeline: SAGARenderPipeline? + private var vertexBuffers: [Int: BufferBinding] = [:] + private var uniforms: [ComputeBindingSlot: BufferBinding] = [:] + private var textures: [ComputeBindingSlot: SAGAGPUTexture] = [:] + private var samplers: [ComputeBindingSlot: SAGASampler] = [:] + private var index: (buffer: SAGABuffer, format: IndexFormat, offset: Int)? + private var viewport: RenderViewport? + private var scissor: RenderScissor? + + init(buffer: SAGACommandBuffer, descriptor: RenderPassDescriptor) { + self.buffer = buffer + self.colors = descriptor.colorAttachments.compactMap { attachment in + guard let texture = attachment.texture.gpuTexture as? SAGAGPUTexture, + let format = texture.pixelFormat.sagaFormat else { return nil } + let loads = attachment.operation?.loadAction ?? .clear + let color = attachment.clearColor ?? AdaUtils.Color.black + return PassColor( + slot: texture.slot, + clear: loads == .clear + ? RenderClearColor( + red: Double(color.red), green: Double(color.green), + blue: Double(color.blue), alpha: Double(color.alpha) + ) : nil, + store: .store, + resolve: (attachment.resolveTexture?.gpuTexture as? SAGAGPUTexture)?.slot, + format: format, + owners: [texture] + [attachment.resolveTexture?.gpuTexture as? SAGAGPUTexture].compactMap { $0 } + ) + } + if let attachment = descriptor.depthStencilAttachment, + let texture = attachment.texture.gpuTexture as? SAGAGPUTexture, + let depthFormat = texture.pixelFormat.sagaFormat { + let hasStencil = texture.pixelFormat == .depth24_stencil8 || texture.pixelFormat == .depth_32f_stencil8 + self.depth = PassDepth( + slot: texture.slot, + depthClear: (attachment.depthOperation?.loadAction ?? .clear) == .clear ? 1 : nil, + depthStore: .store, + stencilClear: (attachment.stencilOperation?.loadAction ?? .clear) == .clear ? 1 : nil, + stencilStore: .store, + hasStencil: hasStencil, + format: depthFormat, + owner: texture + ) + } else { + self.depth = nil + } + } + + func pushDebugName(_: String) {} + func popDebugName() {} + + func setRenderPipelineState(_ pipeline: RenderPipeline) { + self.pipeline = pipeline as? SAGARenderPipeline + } + + // MARK: Resources + + private func binding(_ buffer: any Buffer, offset: Int) -> BufferBinding? { + guard let buffer = buffer as? SAGABuffer else { return nil } + return BufferBinding(buffer: buffer, offset: offset, size: buffer.length - offset) + } + + func setVertexBuffer(_ buffer: UniformBuffer, offset: Int, slot: Int) { + uniforms[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = binding(buffer, offset: offset) + } + + func setVertexBuffer(_ buffer: VertexBuffer, offset: Int, slot: Int) { + vertexBuffers[slot] = binding(buffer, offset: offset) + } + + func setFragmentBuffer(_ buffer: UniformBuffer, offset: Int, slot: Int) { + uniforms[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = binding(buffer, offset: offset) + } + + func setVertexBuffer(_ bufferData: BufferData, offset: Int, slot: Int) { + guard let raw = bufferData.buffer, let bound = binding(raw, offset: offset) else { return } + uniforms[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = bound + vertexBuffers[slot] = bound + } + + func setFragmentBuffer(_ bufferData: BufferData, offset: Int, slot: Int) { + guard let raw = bufferData.buffer else { return } + uniforms[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = binding(raw, offset: offset) + } + + func setIndexBuffer(_ bufferData: BufferData, indexFormat: IndexBufferFormat) { + guard let buffer = bufferData.buffer as? SAGABuffer else { return } + index = (buffer, indexFormat == .uInt32 ? .uint32 : .uint16, 0) + } + + func setIndexBuffer(_ buffer: IndexBuffer, offset: Int) { + guard let buffer = buffer as? SAGAIndexBuffer else { return } + index = (buffer, buffer.indexFormat == .uInt32 ? .uint32 : .uint16, offset) + } + + func setVertexBytes(_ bytes: UnsafeRawPointer, length: Int, slot: Int) { + // Inline bytes get a fresh uniform buffer, as on the other backends. + let inline = SAGAUniformBuffer(context: buffer.context, length: length, binding: slot) + unsafe inline.setData(UnsafeMutableRawPointer(mutating: bytes), byteCount: length, offset: 0) + uniforms[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = BufferBinding( + buffer: inline, offset: 0, size: inline.length + ) + } + + func setFragmentTexture(_ texture: Texture, slot: Int) { + textures[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = texture.gpuTexture as? SAGAGPUTexture + } + + func setFragmentSamplerState(_ sampler: Sampler, slot: Int) { + samplers[ComputeBindingSlot(group: 0, binding: UInt32(slot))] = sampler as? SAGASampler + } + + func setResourceSet(_ resourceSet: RenderResourceSet, index set: Int) { + for entry in resourceSet.bindings { + let slot = ComputeBindingSlot(group: UInt32(set), binding: UInt32(entry.binding)) + switch entry.resource { + case let .uniformBuffer(buffer, offset): + uniforms[slot] = binding(buffer, offset: offset) + case let .texture(texture): + textures[slot] = texture.gpuTexture as? SAGAGPUTexture + case let .sampler(sampler): + samplers[slot] = sampler as? SAGASampler + } + } + } + + func setViewport(_ viewport: Rect) { + self.viewport = RenderViewport( + x: Float(viewport.origin.x), y: Float(viewport.origin.y), + width: Float(viewport.size.width), height: Float(viewport.size.height) + ) + } + + func setScissorRect(_ rect: Rect) { + scissor = RenderScissor( + x: UInt32(max(0, rect.origin.x)), y: UInt32(max(0, rect.origin.y)), + width: UInt32(max(0, rect.size.width)), height: UInt32(max(0, rect.size.height)) + ) + } + + func setTriangleFillMode(_: TriangleFillMode) {} + + // MARK: Draws + + 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 let index, let state = captureState() else { return } + let draw = IndexedDraw( + indexCount: UInt32(indexCount), + firstIndex: UInt32(indexBufferOffset / Int(index.format.byteSize)), + instances: DrawInstances(count: UInt32(instanceCount), first: UInt32(firstInstance)) + ) + let indexSlot = index.buffer.slot + let indexBinding = (format: index.format, offset: UInt64(index.offset), length: UInt64(index.buffer.length - index.offset)) + emit { target in + .renderIndexed( + pipeline: try state.pipeline.slot.get(), + target: target.color0, + colorLoad: target.colorLoad, + indexBuffer: IndexBufferBinding( + buffer: try indexSlot.get(), format: indexBinding.format, + offset: indexBinding.offset, size: indexBinding.length + ), + draw: draw, + vertexBuffers: try state.vertexBindings(), + uniforms: try state.uniformBindings(), + textures: try state.textureBindings(), + samplers: try state.samplerBindings(), + depth: target.depth, + resolve: target.resolve, + additionalColors: target.additionalColors, + raster: state.raster, + storageBuffers: try state.storageBindings(), + colorStore: target.colorStore + ) + } + } + + func draw(type _: IndexPrimitive, vertexStart: Int, vertexCount: Int, instanceCount: Int) { + guard let state = captureState() else { return } + emit { target in + .render( + pipeline: try state.pipeline.slot.get(), + target: target.color0, + colorLoad: target.colorLoad, + vertexCount: UInt32(vertexCount), + vertexBuffers: try state.vertexBindings(), + firstVertex: UInt32(vertexStart), + instances: DrawInstances(count: UInt32(instanceCount)), + uniforms: try state.uniformBindings(), + textures: try state.textureBindings(), + samplers: try state.samplerBindings(), + depth: target.depth, + resolve: target.resolve, + additionalColors: target.additionalColors, + raster: state.raster, + storageBuffers: try state.storageBindings(), + colorStore: target.colorStore + ) + } + } + + func endRenderPass() { + guard !drewInPass, !colors.isEmpty || depth != nil else { return } + // No draw recorded: draw a degenerate triangle so the pass still applies its clears. + let clear = buffer.context.clearPipeline(colors: colors.map(\.format), depth: depth?.format) + emit { target in + .render( + pipeline: try clear.get(), + target: target.color0, + colorLoad: target.colorLoad, + vertexCount: 3, + depth: target.depth, + resolve: target.resolve, + additionalColors: target.additionalColors, + colorStore: target.colorStore + ) + } + } + + // MARK: Capture + + /// Everything a draw reads, frozen at encode time. Resolved to resource ids on the device queue. + private struct DrawState: @unchecked Sendable { + let pipeline: SAGARenderPipeline + let vertex: [(slot: UInt32, buffer: ResourceSlot, offset: UInt64)] + let uniforms: [(ComputeBindingSlot, ResourceSlot, UInt64, UInt64)] + let textures: [(ComputeBindingSlot, ResourceSlot)] + let samplers: [(ComputeBindingSlot, ResourceSlot)] + let raster: RenderRasterState + let retained: [AnyObject] + + func vertexBindings() throws -> [VertexBufferBinding] { + try vertex.map { VertexBufferBinding(buffer: try $0.buffer.get(), offset: $0.offset, slot: $0.slot) } + } + + func uniformBindings() throws -> [ComputeBindingSlot: ComputeBufferBinding] { + var result: [ComputeBindingSlot: ComputeBufferBinding] = [:] + for (slot, buffer, offset, size) in uniforms where pipeline.uniformSlots.contains(slot) { + result[slot] = ComputeBufferBinding(buffer: try buffer.get(), offset: offset, size: size) + } + return result + } + + func storageBindings() throws -> [ComputeBindingSlot: ComputeBufferBinding] { + var result: [ComputeBindingSlot: ComputeBufferBinding] = [:] + for (slot, buffer, offset, size) in uniforms where pipeline.storageSlots.contains(slot) { + result[slot] = ComputeBufferBinding(buffer: try buffer.get(), offset: offset, size: size) + } + return result + } + + func textureBindings() throws -> [ComputeBindingSlot: ResourceID] { + var result: [ComputeBindingSlot: ResourceID] = [:] + for (slot, texture) in textures where pipeline.textureSlots.contains(slot) { + result[slot] = try texture.get() + } + return result + } + + func samplerBindings() throws -> [ComputeBindingSlot: ResourceID] { + var result: [ComputeBindingSlot: ResourceID] = [:] + for (slot, sampler) in samplers where pipeline.samplerSlots.contains(slot) { + result[slot] = try sampler.get() + } + return result + } + } + + private func captureState() -> DrawState? { + guard let pipeline else { return nil } + var retained: [AnyObject] = [] + var vertex: [(slot: UInt32, buffer: ResourceSlot, offset: UInt64)] = [] + for (engineSlot, gpuSlot) in pipeline.vertexBufferSlots.sorted(by: { $0.value < $1.value }) { + guard let bound = vertexBuffers[engineSlot] else { continue } + vertex.append((gpuSlot, bound.buffer.slot, UInt64(bound.offset))) + retained.append(bound.buffer) + } + let uniformList = uniforms.map { slot, bound -> (ComputeBindingSlot, ResourceSlot, UInt64, UInt64) in + retained.append(bound.buffer) + return (slot, bound.buffer.slot, UInt64(bound.offset), UInt64(bound.size)) + } + let textureList = textures.map { slot, texture -> (ComputeBindingSlot, ResourceSlot) in + retained.append(texture) + return (slot, texture.slot) + } + let samplerList = samplers.map { slot, sampler -> (ComputeBindingSlot, ResourceSlot) in + retained.append(sampler) + return (slot, sampler.slot) + } + retained.append(pipeline) + if buffer.context.debugErrors, !pipeline.reportedMissingBindings { + pipeline.reportedMissingBindings = true + func names(_ slots: Set) -> String { + slots.map { "\($0.group):\($0.binding)" }.sorted().joined(separator: ",") + } + let missingBuffers = pipeline.uniformSlots.union(pipeline.storageSlots).subtracting(uniforms.keys) + let missingTextures = pipeline.textureSlots.subtracting(textures.keys) + let missingSamplers = pipeline.samplerSlots.subtracting(samplers.keys) + let vd = pipeline.descriptor.vertexDescriptor + let attrs = vd.attributes.buffer.enumerated().compactMap { index, a -> String? in + a.format == .invalid ? nil : "loc\(index):\(a.name)@buf\(a.bufferIndex)+\(a.offset)/\(a.format)" + } + let layouts = vd.activeBufferIndices.map { i in "buf\(i): stride=\(vd.layouts.buffer[i].stride) \(vd.layouts.buffer[i].stepFunction)" } + print("[SAGA] pipeline '\(pipeline.descriptor.debugName)' vertex attrs=\(attrs) layouts=\(layouts) mapped=\(pipeline.vertexBufferSlots) boundEngineSlots=\(vertexBuffers.keys.sorted()) shaderInputs=\(pipeline.vertexInputLocations)") + print("[SAGA] pipeline '\(pipeline.descriptor.debugName)' declares uniforms=\(names(pipeline.uniformSlots)) textures=\(names(pipeline.textureSlots)) samplers=\(names(pipeline.samplerSlots)); unbound uniforms=\(names(missingBuffers)) textures=\(names(missingTextures)) samplers=\(names(missingSamplers))") + } + return DrawState( + pipeline: pipeline, + vertex: vertex, + uniforms: uniformList, + textures: textureList, + samplers: samplerList, + raster: RenderRasterState(viewport: viewport, scissor: scissor), + retained: retained + ) + } + + /// Attachments for one draw. The first draw of the pass applies the clears; later draws load. + private struct DrawTarget { + let color0: ResourceID? + let colorLoad: RenderColorLoadAction + let colorStore: RenderStoreAction + let resolve: RenderResolveAttachment? + let additionalColors: [UInt32: RenderColorAttachment] + let depth: RenderDepthAttachment? + } + + private func emit(_ make: @escaping @Sendable (DrawTarget) throws -> BufferCommand) { + let first = !drewInPass + drewInPass = true + let colors = self.colors + let depth = self.depth + buffer.append { + var additional: [UInt32: RenderColorAttachment] = [:] + for (offset, color) in colors.enumerated().dropFirst() { + additional[UInt32(offset)] = RenderColorAttachment( + texture: try color.slot.get(), + loadAction: first ? (color.clear.map { .clear($0) } ?? .load) : .load, + storeAction: color.store + ) + } + let depthAttachment = try depth.map { depth -> RenderDepthAttachment in + RenderDepthAttachment( + texture: try depth.slot.get(), + loadAction: first ? (depth.depthClear.map { .clear($0) } ?? .load) : .load, + stencil: depth.hasStencil + ? RenderStencilAttachmentState( + loadAction: first ? (depth.stencilClear.map { .clear($0) } ?? .load) : .load, + storeAction: depth.stencilStore + ) : nil, + storeAction: depth.depthStore + ) + } + let first0 = colors.first + let target = DrawTarget( + color0: try first0?.slot.get(), + colorLoad: first ? (first0?.clear.map { .clear($0) } ?? .load) : .load, + colorStore: first0?.store ?? .store, + resolve: try first0?.resolve.map { RenderResolveAttachment(texture: try $0.get()) }, + additionalColors: additional, + depth: depthAttachment + ) + return try make(target) + } + } + } + + // MARK: - Blit encoder + + final class SAGABlitCommandEncoder: BlitCommandEncoder, @unchecked Sendable { + private let buffer: SAGACommandBuffer + + init(buffer: SAGACommandBuffer) { + self.buffer = buffer + } + + func pushDebugName(_: String) {} + func popDebugName() {} + + func copyTextureToTexture( + source: Texture, sourceOrigin: Origin3D, sourceSize: Size3D, sourceMipLevel: Int, sourceSlice: Int, + destination: Texture, destinationOrigin: Origin3D, destinationMipLevel: Int, destinationSlice: Int + ) { + guard let from = source.gpuTexture as? SAGAGPUTexture, let to = destination.gpuTexture as? SAGAGPUTexture else { return } + let region = TextureCopyRegion( + x: UInt32(sourceOrigin.x), y: UInt32(sourceOrigin.y), + width: UInt32(sourceSize.width), height: UInt32(sourceSize.height), + mipLevel: UInt32(sourceMipLevel), arrayLayer: UInt32(sourceSlice) + ) + let origin = TextureCopyOrigin( + x: UInt32(destinationOrigin.x), y: UInt32(destinationOrigin.y), + mipLevel: UInt32(destinationMipLevel), arrayLayer: UInt32(destinationSlice) + ) + let (src, dst) = (from.slot, to.slot) + buffer.append { + .copyTextureToTexture(source: try src.get(), destination: try dst.get(), sourceRegion: region, destinationOrigin: origin) + } + } + + func copyBufferToBuffer(source: Buffer, sourceOffset: Int, destination: Buffer, destinationOffset: Int, size: Int) { + guard let from = source as? SAGABuffer, let to = destination as? SAGABuffer else { return } + let (src, dst) = (from.slot, to.slot) + buffer.append { + .copy( + source: try src.get(), sourceOffset: UInt64(sourceOffset), + destination: try dst.get(), destinationOffset: UInt64(destinationOffset), byteCount: UInt64(size) + ) + } + } + + func copyBufferToTexture( + source: Buffer, sourceOffset: Int, sourceBytesPerRow: Int, sourceBytesPerImage: Int, sourceSize: Size3D, + destination: Texture, destinationOrigin: Origin3D, destinationMipLevel: Int, destinationSlice: Int + ) { + guard let from = source as? SAGABuffer, let to = destination.gpuTexture as? SAGAGPUTexture else { return } + let layout = TextureBufferLayout( + offset: UInt64(sourceOffset), bytesPerRow: UInt32(sourceBytesPerRow), + rowsPerImage: sourceBytesPerRow > 0 ? UInt32(sourceBytesPerImage / sourceBytesPerRow) : nil + ) + let region = TextureCopyRegion( + x: UInt32(destinationOrigin.x), y: UInt32(destinationOrigin.y), + width: UInt32(sourceSize.width), height: UInt32(sourceSize.height), + mipLevel: UInt32(destinationMipLevel), arrayLayer: UInt32(destinationSlice) + ) + let (src, dst) = (from.slot, to.slot) + buffer.append { + .copyBufferToTexture(source: try src.get(), layout: layout, destination: try dst.get(), region: region) + } + } + + func copyTextureToBuffer( + source: Texture, sourceOrigin: Origin3D, sourceMipLevel: Int, sourceSlice: Int, sourceSize: Size3D, + destination: Buffer, destinationOffset: Int, destinationBytesPerRow: Int, destinationBytesPerImage: Int + ) { + guard let from = source.gpuTexture as? SAGAGPUTexture, let to = destination as? SAGABuffer else { return } + let layout = TextureBufferLayout( + offset: UInt64(destinationOffset), bytesPerRow: UInt32(destinationBytesPerRow), + rowsPerImage: destinationBytesPerRow > 0 ? UInt32(destinationBytesPerImage / destinationBytesPerRow) : nil + ) + let region = TextureCopyRegion( + x: UInt32(sourceOrigin.x), y: UInt32(sourceOrigin.y), + width: UInt32(sourceSize.width), height: UInt32(sourceSize.height), + mipLevel: UInt32(sourceMipLevel), arrayLayer: UInt32(sourceSlice) + ) + let (src, dst) = (from.slot, to.slot) + buffer.append { + .copyTextureRegionToBuffer(source: try src.get(), destination: try dst.get(), layout: layout, region: region) + } + } + + func endBlitPass() {} + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGAContext+Browser.swift b/Sources/AdaRender/Backends/SAGA/SAGAContext+Browser.swift new file mode 100644 index 000000000..71afa2cf5 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGAContext+Browser.swift @@ -0,0 +1,38 @@ +// +// SAGAContext+Browser.swift +// AdaEngine +// + +#if SAGA_RENDER && SAGA_WEBGPU && canImport(SAGAWebHost) && canImport(SAGACompiler) + import SAGA + import SAGACore + import SAGAShaderIR + import SAGATypes + import SAGAWebGPU + import SAGAWebHost + + extension SAGAContext { + /// A context that renders through SAGA's JavaScript WebGPU host, reached over `transport`. + convenience init( + capabilities: DeviceCapabilities, + supportsPresentation: Bool, + debugErrors: Bool = false, + transport: some BrowserTransport + ) { + self.init( + capabilities: capabilities, + supportsPresentation: supportsPresentation, + shaderOutput: .wgsl, + debugErrors: debugErrors, + makeDevice: { + let host = makeWebGPUHost( + capabilities: capabilities, + supportsPresentation: supportsPresentation, + transport: transport + ) + return try await Device.make(backend: BrowserComputeBackend(host: host)) + } + ) + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGAContext+Vulkan.swift b/Sources/AdaRender/Backends/SAGA/SAGAContext+Vulkan.swift new file mode 100644 index 000000000..99b03efaa --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGAContext+Vulkan.swift @@ -0,0 +1,31 @@ +// +// SAGAContext+Vulkan.swift +// AdaEngine +// + +#if SAGA_RENDER && SAGA_NATIVE && canImport(SAGAVulkan) && canImport(SAGACompiler) + import SAGA + import SAGACore + import SAGAShaderIR + import SAGATypes + import SAGAVulkan + + extension SAGAContext { + /// Whether a Vulkan driver with a usable adapter is installed. + static var isVulkanAvailable: Bool { + VulkanBackend.requestAdapter() != nil + } + + /// A context that renders offscreen through SAGA's Vulkan runtime. Presentation is not wired yet. + static func vulkan(debugErrors: Bool = false) throws -> SAGAContext { + let backend = try VulkanBackend.makeDevice() + return SAGAContext( + capabilities: backend.capabilities, + supportsPresentation: false, + shaderOutput: .spirv, + debugErrors: debugErrors, + makeDevice: { try await Device.make(backend: backend) } + ) + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGAContext.swift b/Sources/AdaRender/Backends/SAGA/SAGAContext.swift new file mode 100644 index 000000000..8b7637714 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGAContext.swift @@ -0,0 +1,130 @@ +// +// SAGAContext.swift +// AdaEngine +// +// Owns the SAGA device that backs a SAGA render path (browser WebGPU or native Vulkan). +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import AdaUtils + import Foundation + import Logging + import SAGA + import SAGABackend + import SAGACompiler + import SAGAShaderIR + import SAGACore + import SAGATypes + import Synchronization + + /// Thread-safe list of device error messages. + final class SAGAErrorLog: Sendable { + private let messages = Mutex<[String]>([]) + var all: [String] { messages.withLock { $0 } } + func record(_ message: String) { messages.withLock { $0.append(message) } } + } + + /// Shared state for the SAGA render backend. + /// + /// SAGA's `Device` is an actor with asynchronous resource creation. AdaRender's API is synchronous, + /// so every device operation goes through ``queue``, which preserves submission order. + final class SAGAContext: Sendable { + let capabilities: DeviceCapabilities + let supportsPresentation: Bool + let queue: OrderedGPUWorkQueue + /// Waits for each submission and logs device errors. Enabled by `globalThis.__adaSAGADebug = true`. + /// `ADAENGINE_SAGA_DEBUG=1` turns on error and missing-binding reports for every context. + static var debugFromEnvironment: Bool { ProcessInfo.processInfo.environment["ADAENGINE_SAGA_DEBUG"] == "1" } + + let debugErrors: Bool + let logger = Logger(label: "org.adaengine.saga") + + /// The shader format the device consumes: WGSL in the browser, SPIR-V on Vulkan. + let shaderOutput: ShaderOutputFormat + /// Messages of failed device operations, kept for diagnostics and tests. + let errorLog = SAGAErrorLog() + + /// - Parameter makeDevice: Creates the SAGA `Device`. It runs on the first queued operation. + init( + capabilities: DeviceCapabilities, + supportsPresentation: Bool, + shaderOutput: ShaderOutputFormat, + debugErrors: Bool = SAGAContext.debugFromEnvironment, + makeDevice: @escaping @Sendable () async throws -> Device + ) { + self.debugErrors = debugErrors + if debugErrors { + FileHandle.standardError.write(Data("[SAGA] debug error logging enabled\n".utf8)) + } + self.capabilities = capabilities + self.supportsPresentation = supportsPresentation + self.shaderOutput = shaderOutput + let errorLog = self.errorLog + self.queue = OrderedGPUWorkQueue( + context: makeDevice, + onError: { error in + errorLog.record("\(error)") + FileHandle.standardError.write(Data("[SAGA] device operation failed: \(error)\n".utf8)) + } + ) + } + + /// Creates a SAGA buffer without blocking and returns the slot its id is written to. + func createBuffer(_ descriptor: BufferDescriptor) -> ResourceSlot { + let slot = ResourceSlot() + queue.enqueue { device in + slot.fulfill(try await device.createBuffer(descriptor)) + } + return slot + } + + private let clearPipelines = Mutex<[String: ResourceSlot]>([:]) + + /// A pipeline that draws a degenerate triangle. A pass that records no draw uses it so its clears still run. + func clearPipeline(colors: [TextureFormat], depth: TextureFormat?) -> ResourceSlot { + let key = colors.map { String($0.rawValue) }.joined(separator: ",") + "|" + (depth.map { String($0.rawValue) } ?? "-") + return clearPipelines.withLock { cache in + if let existing = cache[key] { return existing } + let slot = ResourceSlot() + cache[key] = slot + let output = shaderOutput + queue.enqueue { device in + var outputs = "" + var assignments = "" + for index in colors.indices { + outputs += "@location(\(index)) c\(index): vec4, " + assignments += "out.c\(index) = vec4(0.0); " + } + let source = """ + struct Out { \(outputs) } + @vertex fn vs() -> @builtin(position) vec4 { return vec4(0.0, 0.0, 0.0, 1.0); } + @fragment fn fs() -> Out { var out: Out; \(assignments) return out; } + """ + let compiler = IndependentShaderCompiler() + let vertex = try compiler.compileRenderStage(.wgsl(source), entryPoint: "vs", to: output) + let fragment = colors.isEmpty ? nil : try compiler.compileRenderStage(.wgsl(source), entryPoint: "fs", to: output) + slot.fulfill( + try await device.createRenderPipeline( + vertex: vertex, + fragment: fragment, + colorFormat: colors.first, + depthState: depth.map { RenderDepthState(format: $0, compare: .always, writeEnabled: false) }, + additionalColorTargets: Dictionary( + uniqueKeysWithValues: colors.enumerated().dropFirst().map { (UInt32($0.offset), RenderColorTarget(format: $0.element)) } + ) + ) + ) + } + return slot + } + } + + func createSampler(_ descriptor: SAGATypes.SamplerDescriptor) -> ResourceSlot { + let slot = ResourceSlot() + queue.enqueue { device in + slot.fulfill(try await device.createSampler(descriptor)) + } + return slot + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGAGPUTexture.swift b/Sources/AdaRender/Backends/SAGA/SAGAGPUTexture.swift new file mode 100644 index 000000000..5432a1803 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGAGPUTexture.swift @@ -0,0 +1,184 @@ +// +// SAGAGPUTexture.swift +// AdaEngine +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import Foundation + import Math + import SAGA + import SAGACore + import SAGATypes + + enum SAGATextureError: Error { + case unsupportedPixelFormat + case readbackUnavailable + } + + extension PixelFormat { + /// Browser mapping; depth/stencil uses the 24-bit variant WebGPU guarantees. + var sagaFormat: TextureFormat? { + switch self { + case .none: nil + case .bgra8: .bgra8Unorm + case .bgra8_srgb: .bgra8UnormSrgb + case .rgba8: .rgba8Unorm + case .rgba_16f: .rgba16Float + case .rgba_32f: .rgba32Float + case .r32f: .r32Float + case .depth_32f_stencil8, .depth24_stencil8: .depth24PlusStencil8 + case .depth_32f: .depth32Float + } + } + } + + @_spi(Internal) + public final class SAGAGPUTexture: GPUTexture, TextureSubresourceUploading, @unchecked Sendable { + public let size: SizeInt + public var label: String? + let context: SAGAContext + let slot: ResourceSlot + let pixelFormat: PixelFormat + let allowsBindingCache: Bool + let allowsTransientBindingCache: Bool + private let ownsResource: Bool + + init(context: SAGAContext, descriptor: TextureDescriptor) throws { + guard let format = descriptor.pixelFormat.sagaFormat else { + throw SAGATextureError.unsupportedPixelFormat + } + self.context = context + self.size = SizeInt(width: descriptor.width, height: descriptor.height) + self.label = descriptor.debugLabel + self.pixelFormat = descriptor.pixelFormat + self.allowsBindingCache = descriptor.allowsBindingCache && !descriptor.textureUsage.contains(.renderTarget) + self.allowsTransientBindingCache = descriptor.allowsBindingCache + self.ownsResource = true + + var usage: TextureUsage = [] + if descriptor.textureUsage.contains(.read) { + usage.formUnion([.copyDestination, .copySource, .textureBinding]) + } + if descriptor.textureUsage.contains(.write) { + usage.insert(.copySource) + } + if descriptor.textureUsage.contains(.renderTarget) { + usage.formUnion([.renderAttachment, .copySource]) + } else { + usage.insert(.textureBinding) + } + // Uploads need a copy destination even for textures declared write-only. + usage.insert(.copyDestination) + + let layers: Int = descriptor.textureType == .textureCube + ? 6 : (descriptor.textureType == .texture2DArray ? max(1, descriptor.arrayLength) : 1) + let sagaDescriptor = SAGATypes.TextureDescriptor( + width: UInt32(descriptor.width), + height: UInt32(descriptor.height), + format: format, + usage: usage, + mipLevelCount: UInt32(max(1, descriptor.mipmapLevel)), + arrayLayerCount: UInt32(layers), + dimension: descriptor.textureType == .texture3D ? .d3 : (descriptor.textureType == .texture1D ? .d1 : .d2) + ) + let slot = ResourceSlot() + self.slot = slot + context.queue.enqueue { device in + slot.fulfill(try await device.createTexture(sagaDescriptor)) + } + + let initial = descriptor.image.map { [TextureSubresource(image: $0, mipLevel: 0)] } ?? [] + for subresource in initial + descriptor.subresources { + try? uploadSubresource(subresource) + } + } + + deinit { + guard ownsResource else { return } + let slot = self.slot + context.queue.enqueue { device in + if let id = try? slot.get() { + try await device.destroyTexture(id) + } + } + } + + public func replaceRegion(_ region: RectInt, mipmapLevel: Int, withBytes bytes: UnsafeRawPointer, bytesPerRow: Int) { + let data = unsafe [UInt8](UnsafeRawBufferPointer(start: bytes, count: bytesPerRow * region.size.height)) + write( + data, + bytesPerRow: bytesPerRow, + region: TextureCopyRegion( + x: UInt32(region.origin.x), y: UInt32(region.origin.y), + width: UInt32(region.size.width), height: UInt32(region.size.height), + mipLevel: UInt32(mipmapLevel) + ) + ) + } + + func uploadSubresource(_ resource: TextureSubresource) throws { + let image = resource.image + let bytesPerRow = pixelFormat.bytesPerComponent * image.width + let data = unsafe image.data.withUnsafeBytes { unsafe [UInt8]($0) } + write( + data, + bytesPerRow: bytesPerRow, + region: TextureCopyRegion( + width: UInt32(image.width), height: UInt32(image.height), + mipLevel: UInt32(resource.mipLevel), arrayLayer: UInt32(resource.slice) + ) + ) + } + + private func write(_ data: [UInt8], bytesPerRow: Int, region: TextureCopyRegion) { + let slot = self.slot + let layout = TextureDataLayout(bytesPerRow: UInt32(bytesPerRow), rowsPerImage: region.height) + context.queue.enqueue { device in + _ = try await device.writeTexture(try slot.get(), data: data, layout: layout, region: region) + } + } + + /// Reads mip 0 through an aligned staging buffer. Only 8-bit color formats convert to ``Image``. + func readImage() async throws -> Image? { + let imageFormat: Image.Format + switch pixelFormat { + case .bgra8: imageFormat = .bgra8 + case .bgra8_srgb: imageFormat = .bgra8_sRGB + case .rgba8: imageFormat = .rgba8 + default: return nil + } + let width = size.width + let height = size.height + let bytesPerRow = width * 4 + let padded = (bytesPerRow + 255) & ~255 + let staging = context.createBuffer( + BufferDescriptor(label: "texture-readback", size: UInt64(padded * height), usage: [.copyDestination, .mapRead]) + ) + let source = self.slot + let bytes: [UInt8] = try await withCheckedThrowingContinuation { continuation in + context.queue.enqueue { device in + do { + let src = try source.get() + let dst = try staging.get() + _ = try await device.submitCommands([ + .copyTextureToBuffer(source: src, destination: dst, bytesPerRow: UInt32(padded)) + ]).wait() + let mapping = try await device.mapBuffer(dst, mode: .read) + let data = try await device.readMappedBytes(mapping) + try await device.unmapBuffer(dst) + try await device.destroyBuffer(dst) + continuation.resume(returning: data) + } catch { + continuation.resume(throwing: error) + } + } + } + var pixels = Data(capacity: bytesPerRow * height) + for row in 0.. UInt32 { + host[name].boolean == true || (host[name].number ?? 0) != 0 ? 1 : 0 + } + func number(_ name: String) -> UInt32 { + UInt32(clamping: Int(host[name].number ?? 0)) + } + maxBufferSize = UInt64(host.maxBufferSize.number ?? 0) + maxTextureDimension2D = number("maxTextureDimension2D") + presentation = flag("supportsPresentation") + readableStorage = flag("supportsReadableStorage") + shaderF16 = flag("supportsShaderF16") + uniformBufferStandardLayout = flag("supportsUniformBufferStandardLayout") + indirectFirstInstance = flag("supportsIndirectFirstInstance") + clipDistances = flag("supportsClipDistances") + primitiveIndex = flag("supportsPrimitiveIndex") + depthClipControl = flag("supportsDepthClipControl") + timestampQuery = flag("supportsTimestampQuery") + subgroups = flag("supportsSubgroups") + subgroupID = flag("supportsSubgroupID") + dualSourceBlending = flag("supportsDualSourceBlending") + textureCompressionBC = flag("supportsTextureCompressionBC") + textureCompressionETC2 = flag("supportsTextureCompressionETC2") + textureCompressionASTC = flag("supportsTextureCompressionASTC") + textureCompressionBCSliced3D = flag("supportsTextureCompressionBCSliced3D") + textureCompressionASTCSliced3D = flag("supportsTextureCompressionASTCSliced3D") + float32Filterable = flag("supportsFloat32Filtering") + float32Blendable = flag("supportsFloat32Blending") + multisampledFloat32Mask = number("multisampledFloat32Mask") + textureFormatsTier1 = flag("supportsTextureFormatsTier1") + textureFormatsTier2 = flag("supportsTextureFormatsTier2") + rg11b10UfloatRenderable = flag("supportsRG11B10Render") + depth32FloatStencil8 = flag("supportsDepth32FloatStencil8") + bgra8UnormStorage = flag("supportsBGRA8UnormStorage") + maxImmediateSize = number("maxImmediateSize") + linearIndexing = flag("supportsLinearIndexing") + subgroupSizeControl = flag("supportsSubgroupSizeControl") + subgroupMinSize = number("subgroupMinSize") + subgroupMaxSize = number("subgroupMaxSize") + subgroupUniformity = flag("supportsSubgroupUniformity") + maxComputeWorkgroupStorageSize = number("maxComputeWorkgroupStorageSize") + maxComputeInvocationsPerWorkgroup = number("maxComputeInvocationsPerWorkgroup") + let workgroup = host.maxComputeWorkgroupSize.object + maxComputeWorkgroupSizeX = UInt32(clamping: Int(workgroup?.x.number ?? 0)) + maxComputeWorkgroupSizeY = UInt32(clamping: Int(workgroup?.y.number ?? 0)) + maxComputeWorkgroupSizeZ = UInt32(clamping: Int(workgroup?.z.number ?? 0)) + maxComputeWorkgroupsPerDimension = number("maxComputeWorkgroupsPerDimension") + maxVertexAttributes = number("maxVertexAttributes") + maxVertexBuffers = number("maxVertexBuffers") + maxVertexBufferArrayStride = number("maxVertexBufferArrayStride") + vertexFormatSnorm1010102 = flag("supportsVertexFormatSnorm1010102") + maxInterStageShaderVariables = number("maxInterStageShaderVariables") + maxColorAttachments = number("maxColorAttachments") + maxColorAttachmentBytesPerSample = number("maxColorAttachmentBytesPerSample") + maxBindGroupsPlusVertexBuffers = number("maxBindGroupsPlusVertexBuffers") + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGAMetalRenderBackend.swift b/Sources/AdaRender/Backends/SAGA/SAGAMetalRenderBackend.swift new file mode 100644 index 000000000..9f86d2388 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGAMetalRenderBackend.swift @@ -0,0 +1,139 @@ +// AdaRender's Apple window adapter for SAGA. + +#if SAGA_METAL && canImport(SAGAMetal) + import AdaUtils + import Foundation + import Math + import MetalKit + import QuartzCore + import SAGACore + import SAGAMetal + import SAGAShaderIR + import Synchronization + + final class SAGAMetalRenderBackend: RenderBackend, SAGAWindowProvider, Sendable { + private enum SurfaceError: LocalizedError { + case missingMetalLayer + case windowAlreadyRegistered + + var errorDescription: String? { + switch self { + case .missingMetalLayer: "SAGA Metal requires a view with a CAMetalLayer." + case .windowAlreadyRegistered: "A SAGA Metal surface is already registered for this window." + } + } + } + + private struct WindowBinding: Sendable { + let window: SAGAWindow + let scaleFactor: @MainActor @Sendable () -> Float + } + + let context: SAGAContext + private let metal: MetalDevice + private let device: SAGARenderDevice + private let windows = Mutex<[WindowID: WindowBinding]>([:]) + + let type: RenderBackendType = .saga + var renderDevice: RenderDevice { device } + + init() throws { + let metal = try MetalBackend.makePresentableDevice() + self.metal = metal + context = SAGAContext( + capabilities: metal.capabilities, + supportsPresentation: metal.supportsPresentation, + shaderOutput: .msl, + makeDevice: { try await Device.make(backend: metal) } + ) + device = SAGARenderDevice(context: context) + device.windows = self + } + + func createLocalRenderDevice() -> RenderDevice { + SAGARenderDevice(context: context) + } + + @MainActor + func createWindow(_ windowId: WindowID, for surface: RenderSurface, size: SizeInt) throws { + try createWindow(windowId, for: surface, size: size, onPresented: nil) + } + + @MainActor + func createWindow( + _ windowId: WindowID, + for surface: RenderSurface, + size: SizeInt, + onPresented: (@MainActor @Sendable (MetalPresentedFrame) -> Void)? + ) throws { + guard window(for: windowId) == nil else { + throw SurfaceError.windowAlreadyRegistered + } + guard let view = surface as? MTKView else { + throw SurfaceError.missingMetalLayer + } + view.device = metal.metalDevice + view.colorPixelFormat = .bgra8Unorm + view.sampleCount = 1 + guard let layer = view.layer as? CAMetalLayer else { + throw SurfaceError.missingMetalLayer + } + layer.frame = view.bounds + layer.contentsScale = CGFloat(view.scaleFactor) + layer.isOpaque = view.isOpaque + let id = try metal.addSurface(MetalPresentationSurface(layer: layer, onPresented: onPresented)) + let window = SAGAWindow( + context: context, + windowId: windowId, + size: size, + scaleFactor: surface.scaleFactor, + pixelFormat: surface.prefferedPixelFormat, + surfaceID: id + ) + windows.withLock { + $0[windowId] = WindowBinding(window: window, scaleFactor: { [weak view] in view?.scaleFactor ?? 1 }) + } + } + + @MainActor + func resizeWindow(_ windowId: WindowID, newSize: SizeInt) throws { + if let binding = windows.withLock({ $0[windowId] }) { + binding.window.resize(to: newSize, scaleFactor: binding.scaleFactor()) + } + } + + @MainActor + func resizeWindow(_ windowId: WindowID, newSize: SizeInt, scaleFactor: Float) throws { + window(for: windowId)?.resize(to: newSize, scaleFactor: scaleFactor) + } + + @MainActor + func destroyWindow(_ windowId: WindowID) throws { + guard let binding = windows.withLock({ $0.removeValue(forKey: windowId) }) else { + return + } + let metal = self.metal + binding.window.retire { id in + await metal.removeSurface(id) + } + } + + @MainActor + func getRenderWindow(for windowId: WindowID) -> RenderWindow? { + window(for: windowId)?.renderWindow + } + + @MainActor + func getRenderWindows() throws -> RenderWindows { + var result = SparseSet() + for (id, binding) in windows.withLock({ $0 }) { + result.insert(binding.window.renderWindow, for: id) + } + return RenderWindows(windows: result) + } + + func window(for id: WindowID) -> SAGAWindow? { + windows.withLock { $0[id]?.window } + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGANativeRenderBackend.swift b/Sources/AdaRender/Backends/SAGA/SAGANativeRenderBackend.swift new file mode 100644 index 000000000..d95e4748a --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGANativeRenderBackend.swift @@ -0,0 +1,156 @@ +// +// SAGANativeRenderBackend.swift +// AdaEngine +// +// Native render backend on SAGA's Vulkan runtime. +// + +#if SAGA_RENDER && SAGA_NATIVE && (os(Windows) || os(Linux) || os(Android)) && canImport(SAGAVulkan) && canImport(SAGACompiler) + import AdaUtils + import Foundation + import Math + import SAGA + import SAGACore + import SAGAShaderIR + import SAGATypes + import SAGAVulkan + import Synchronization + #if os(Windows) + import WinSDK + #endif + + @_spi(Internal) + public final class SAGANativeRenderBackend: RenderBackend, SAGAWindowProvider, @unchecked Sendable { + enum SetupError: LocalizedError { + case noAdapter + case unsupportedWindow + case windowlessDevice + case windowAlreadyRegistered + + var errorDescription: String? { + switch self { + case .noAdapter: "No Vulkan adapter is available for the SAGA renderer." + case .unsupportedWindow: "The SAGA renderer received an unsupported native window surface." + case .windowlessDevice: "The SAGA device was created for offscreen rendering." + case .windowAlreadyRegistered: "A SAGA surface is already registered for this window." + } + } + } + + let context: SAGAContext + private let vulkan: VulkanDevice + private let device: SAGARenderDevice + private struct WindowBinding: Sendable { + let window: SAGAWindow + let lifetime: (any Sendable)? + } + + private let windows = Mutex<[WindowID: WindowBinding]>([:]) + + var type: RenderBackendType { .saga } + var renderDevice: RenderDevice { device } + + /// - Parameter windowless: Disable swapchain support for offscreen tests and tools. + /// Defaults to `ADAENGINE_SAGA_WINDOWLESS=1`. + init(windowless: Bool = ProcessInfo.processInfo.environment["ADAENGINE_SAGA_WINDOWLESS"] == "1") throws { + guard let adapter = VulkanBackend.requestAdapter() else { throw SetupError.noAdapter } + let backend = windowless + ? try VulkanBackend.makeDevice(adapter: adapter) + : try VulkanBackend.makePresentableDevice(adapter: adapter) + self.vulkan = backend + self.context = SAGAContext( + capabilities: backend.capabilities, + supportsPresentation: backend.supportsPresentation, + shaderOutput: .spirv, + makeDevice: { try await Device.make(backend: backend) } + ) + self.device = SAGARenderDevice(context: context) + self.device.windows = self + } + + func createLocalRenderDevice() -> RenderDevice { + // Textures and samplers made through this device are used by the renderer's passes, so they must live on + // the same SAGA device: a resource id is only valid on the device that created it. + SAGARenderDevice(context: context) + } + + @MainActor + func createWindow(_ windowId: WindowID, for surface: RenderSurface, size: SizeInt) throws { + guard context.supportsPresentation else { throw SetupError.windowlessDevice } + guard window(for: windowId) == nil else { throw SetupError.windowAlreadyRegistered } + let target: VulkanSurfaceTarget + let lifetime: (any Sendable)? + #if os(Windows) + guard let nativeSurface = surface as? WindowsSurface else { throw SetupError.unsupportedWindow } + target = unsafe .win32( + hinstance: UInt(bitPattern: GetModuleHandleW(nil)), + hwnd: UInt(bitPattern: nativeSurface.windowHwnd) + ) + lifetime = nil + #elseif os(Linux) + guard let nativeSurface = surface as? LinuxXlibRenderSurface else { throw SetupError.unsupportedWindow } + target = unsafe .xlib(display: UInt(bitPattern: nativeSurface.xlibDisplay), window: UInt(nativeSurface.xlibWindow)) + lifetime = nativeSurface.xlibWindowLifetime + #elseif os(Android) + guard let nativeSurface = surface as? AndroidNativeWindowRenderSurface else { throw SetupError.unsupportedWindow } + target = unsafe .android(window: UInt(bitPattern: nativeSurface.nativeWindow)) + lifetime = nativeSurface.nativeWindowLifetime + #endif + let window = SAGAWindow( + context: context, + windowId: windowId, + size: size, + scaleFactor: surface.scaleFactor, + pixelFormat: surface.prefferedPixelFormat, + surfaceID: nil + ) + let vulkan = self.vulkan + let surfaceSlot = window.surface + context.queue.enqueue { _ in + defer { withExtendedLifetime(lifetime) {} } + surfaceSlot.fulfill(try await vulkan.addSurface(target)) + } + windows.withLock { $0[windowId] = WindowBinding(window: window, lifetime: lifetime) } + } + + @MainActor + func resizeWindow(_ windowId: WindowID, newSize: SizeInt) throws { + window(for: windowId)?.resize(to: newSize) + } + + @MainActor + func resizeWindow(_ windowId: WindowID, newSize: SizeInt, scaleFactor: Float) throws { + window(for: windowId)?.resize(to: newSize, scaleFactor: scaleFactor) + } + + @MainActor + func destroyWindow(_ windowId: WindowID) throws { + guard let binding = windows.withLock({ $0.removeValue(forKey: windowId) }) else { + return + } + let vulkan = self.vulkan + binding.window.retire { id in + defer { withExtendedLifetime(binding.lifetime) {} } + await vulkan.removeSurface(id) + } + } + + @MainActor + func getRenderWindow(for windowId: WindowID) -> RenderWindow? { + window(for: windowId)?.renderWindow + } + + @MainActor + func getRenderWindows() throws -> RenderWindows { + var set = SparseSet() + for (id, binding) in windows.withLock({ $0 }) { + set.insert(binding.window.renderWindow, for: id) + } + return RenderWindows(windows: set) + } + + func window(for id: WindowID) -> SAGAWindow? { + windows.withLock { $0[id]?.window } + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGARenderBackend.swift b/Sources/AdaRender/Backends/SAGA/SAGARenderBackend.swift new file mode 100644 index 000000000..3776675e3 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGARenderBackend.swift @@ -0,0 +1,113 @@ +// +// SAGARenderBackend.swift +// AdaEngine +// +// Browser render backend on top of SAGA (see Documentation/Designs/saga-migration.md). +// + +#if SAGA_WEBGPU && canImport(SAGAWebHost) && canImport(JavaScriptKit) && canImport(SAGACompiler) + import AdaUtils + import Foundation + import JavaScriptKit + import Math + import SAGA + import SAGACore + import SAGATypes + import SAGAWebHost + import Synchronization + + @_spi(Internal) + public final class SAGARenderBackend: RenderBackend, SAGAWindowProvider, @unchecked Sendable { + enum SetupError: LocalizedError { + case missingHost + + var errorDescription: String? { + "The page did not provide `__adaSAGAHost`. Open the SAGA WebGPU host (`openWebGPU`) before starting the engine." + } + } + + let context: SAGAContext + private let device: SAGARenderDevice + private let windows = Mutex<[WindowID: SAGAWindow]>([:]) + + var type: RenderBackendType { .webgpu } + var renderDevice: RenderDevice { device } + + private let host: JSObject + + init(context: SAGAContext, host: JSObject) { + self.host = host + self.context = context + self.device = SAGARenderDevice(context: context) + self.device.windows = self + } + + static func createBackend() throws -> SAGARenderBackend { + guard let host = JSObject.global.__adaSAGAHost.object else { + throw SetupError.missingHost + } + let parameters = WebHostParameters(host: host) + let capabilities = try makeWebDeviceCapabilities(parameters) + let context = SAGAContext( + capabilities: capabilities, + supportsPresentation: parameters.presentation != 0, + debugErrors: JSObject.global.__adaSAGADebug.boolean == true, + transport: JSBrowserTransport(host: host) + ) + return SAGARenderBackend(context: context, host: host) + } + + func createLocalRenderDevice() -> RenderDevice { + SAGARenderDevice(context: context) + } + + @MainActor + func createWindow(_ windowId: WindowID, for surface: RenderSurface, size: SizeInt) throws { + if let canvasSurface = surface as? BrowserCanvasRenderSurface { + // The host presents into the canvas of the first window it is given. + _ = host.attachCanvas?(canvasSurface.canvas) + } + let window = SAGAWindow( + context: context, + windowId: windowId, + size: size, + scaleFactor: surface.scaleFactor, + pixelFormat: surface.prefferedPixelFormat + ) + windows.withLock { $0[windowId] = window } + } + + @MainActor + func resizeWindow(_ windowId: WindowID, newSize: SizeInt) throws { + windows.withLock { $0[windowId] }?.resize(to: newSize) + } + + @MainActor + func resizeWindow(_ windowId: WindowID, newSize: SizeInt, scaleFactor: Float) throws { + windows.withLock { $0[windowId] }?.resize(to: newSize, scaleFactor: scaleFactor) + } + + @MainActor + func destroyWindow(_ windowId: WindowID) throws { + windows.withLock { _ = $0.removeValue(forKey: windowId) } + } + + @MainActor + func getRenderWindow(for windowId: WindowID) -> RenderWindow? { + windows.withLock { $0[windowId] }?.renderWindow + } + + @MainActor + func getRenderWindows() throws -> RenderWindows { + var set = SparseSet() + for (id, window) in windows.withLock({ $0 }) { + set.insert(window.renderWindow, for: id) + } + return RenderWindows(windows: set) + } + + func window(for id: WindowID) -> SAGAWindow? { + windows.withLock { $0[id] } + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGARenderDevice.swift b/Sources/AdaRender/Backends/SAGA/SAGARenderDevice.swift new file mode 100644 index 000000000..8d5e645d9 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGARenderDevice.swift @@ -0,0 +1,104 @@ +// +// SAGARenderDevice.swift +// AdaEngine +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import AdaUtils + import Foundation + import Math + import SAGA + import SAGACore + import SAGATypes + + @_spi(Internal) + public final class SAGARenderDevice: RenderDevice, @unchecked Sendable { + let context: SAGAContext + weak var windows: (any SAGAWindowProvider)? + + public var supportsFloat32TextureFiltering: Bool { context.capabilities.features.contains(.float32Filterable) } + + // The browser path starts without the optional GPU-driven features of the native backend. + public var supportsInstancedVertexInputs: Bool { true } + public var supportsSpatialUpscaling: Bool { false } + public var supportsTemporalUpscaling: Bool { false } + public var supportsHybridReflections: Bool { false } + public var supportsLightingSurfaceBuffer: Bool { false } + public var supportsMultiDrawIndirect: Bool { false } + + init(context: SAGAContext) { + self.context = context + } + + public func createBuffer(label: String?, length: Int, options _: ResourceOptions) -> any Buffer { + SAGABuffer(context: context, label: label, length: length, usage: ResourceOptions.storageShared.sagaUsage) + } + + public func createBuffer(label: String?, bytes: UnsafeRawPointer, length: Int, options: ResourceOptions) -> any Buffer { + let buffer = createBuffer(label: label, length: length, options: options) + unsafe buffer.setData(UnsafeMutableRawPointer(mutating: bytes), byteCount: length, offset: 0) + return buffer + } + + public func createIndexBuffer(label: String?, format: IndexBufferFormat, bytes: UnsafeRawPointer, length: Int) -> any IndexBuffer { + let buffer = SAGAIndexBuffer(context: context, label: label, format: format, length: length) + unsafe buffer.setData(UnsafeMutableRawPointer(mutating: bytes), byteCount: length, offset: 0) + return buffer + } + + public func createVertexBuffer(label: String?, length: Int, binding: Int) -> any VertexBuffer { + SAGAVertexBuffer(context: context, label: label, length: length, binding: binding) + } + + public func createUniformBuffer(length: Int, binding: Int) -> any UniformBuffer { + SAGAUniformBuffer(context: context, length: length, binding: binding) + } + + public func createUniformBufferSet() -> any UniformBufferSet { + unsafe GenericUniformBufferSet(frames: RenderEngine.configurations.maxFramesInFlight, device: self) + } + + public func compileShader(from shader: Shader) throws -> any CompiledShader { + try SAGAShader(shader: shader, output: context.shaderOutput) + } + + public func createRenderPipeline(from descriptor: RenderPipelineDescriptor) -> any RenderPipeline { + do { + return try SAGARenderPipeline(context: context, descriptor: descriptor) + } catch { + preconditionFailure("Failed to create SAGA render pipeline \(descriptor.debugName): \(error)") + } + } + + public func createSampler(from descriptor: SamplerDescriptor) -> any Sampler { + SAGASampler(context: context, descriptor: descriptor) + } + + public func createTexture(from descriptor: TextureDescriptor) -> any GPUTexture { + do { + return try SAGAGPUTexture(context: context, descriptor: descriptor) + } catch { + preconditionFailure("Failed to create SAGA texture \(descriptor.debugLabel ?? ""): \(error)") + } + } + + public func getImage(from _: Texture) -> Image? { + // Readback is asynchronous; use `readImage(from:)`. + nil + } + + public func readImage(from texture: Texture) async throws -> Image? { + try await (texture.gpuTexture as? SAGAGPUTexture)?.readImage() + } + + public func createCommandQueue() -> any CommandQueue { + SAGACommandQueue(context: context) + } + + @MainActor + public func createSwapchain(from window: WindowID) -> (any Swapchain)? { + guard let window = windows?.window(for: window) else { return nil } + return SAGASwapchain(window: window) + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGARenderPipeline.swift b/Sources/AdaRender/Backends/SAGA/SAGARenderPipeline.swift new file mode 100644 index 000000000..a7180e9d7 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGARenderPipeline.swift @@ -0,0 +1,254 @@ +// +// SAGARenderPipeline.swift +// AdaEngine +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import Foundation + import SAGA + import SAGACompiler + import SAGACore + import SAGAShaderIR + import SAGATypes + + enum SAGAShaderError: Error { + case spirvUnsupported + case notASAGAShader + } + + /// A shader stage translated by SAGA's compiler for the device's shader format. + @_spi(Internal) + public final class SAGAShader: CompiledShader, @unchecked Sendable { + let artifact: RenderStageArtifact + + init(shader: Shader, output: ShaderOutputFormat) throws { + let compiler = IndependentShaderCompiler() + switch shader.source { + case .code(let source): + artifact = try compiler.compileRenderStage(.wgsl(source), entryPoint: shader.entryPoint, to: output) + case .spirv(let data): + let words = unsafe data.withUnsafeBytes { raw in + unsafe Array(raw.bindMemory(to: UInt32.self)) + } + artifact = try compiler.compileRenderStage(.spirv(words), entryPoint: "main", to: output) + } + } + } + + final class SAGARenderPipeline: RenderPipeline, @unchecked Sendable { + let descriptor: RenderPipelineDescriptor + let slot = ResourceSlot() + /// Maps AdaRender vertex buffer indices to the dense slots of the SAGA pipeline. + let vertexBufferSlots: [Int: UInt32] + let context: SAGAContext + /// Debug aid: whether unbound declared resources were already reported. + var reportedMissingBindings = false + let vertexInputLocations: [UInt32] + /// Resources the shader stages declare; the encoder binds only these. + let uniformSlots: Set + let storageSlots: Set + let textureSlots: Set + let samplerSlots: Set + + init(context: SAGAContext, descriptor: RenderPipelineDescriptor) throws { + guard let vertex = descriptor.vertex.compiledShader as? SAGAShader else { + throw SAGAShaderError.notASAGAShader + } + var fragment: SAGAShader? + if let fragmentShader = descriptor.fragment { + guard let compiled = fragmentShader.compiledShader as? SAGAShader else { + throw SAGAShaderError.notASAGAShader + } + fragment = compiled + } + + self.context = context + self.descriptor = descriptor + let active = descriptor.vertexDescriptor.activeBufferIndices + self.vertexBufferSlots = Dictionary(uniqueKeysWithValues: active.enumerated().map { ($0.element, UInt32($0.offset)) }) + + let vertexStage = vertex.artifact + let fragmentStage = fragment?.artifact + let stages = [vertexStage] + (fragmentStage.map { [$0] } ?? []) + func slot(_ binding: IRResourceBinding) -> ComputeBindingSlot { + ComputeBindingSlot(group: binding.group, binding: binding.binding) + } + self.uniformSlots = Set(stages.flatMap { $0.uniformBindings.map { slot($0.binding) } }) + self.storageSlots = Set(stages.flatMap { $0.storageBindings.map { slot($0.binding) } }) + self.textureSlots = Set(stages.flatMap { $0.sampledTextures.map { slot($0.binding) } }) + self.samplerSlots = Set(stages.flatMap { $0.samplerBindings.map { slot($0.binding) } }) + self.vertexInputLocations = vertexStage.vertexInputs.map(\.location) + let layouts = Self.vertexLayouts(for: descriptor) + let colorTargets = descriptor.colorAttachments.compactMap { attachment -> RenderColorTarget? in + guard let format = attachment.format.sagaFormat else { return nil } + return RenderColorTarget(format: format, colorState: attachment.sagaColorState) + } + let primitive = RenderPrimitiveState( + topology: descriptor.primitive.saga, + stripIndexFormat: nil, + // Matches the other backends: an unset winding means clockwise front faces. + frontFace: descriptor.frontFaceWinding == .counterClockwise ? .counterClockwise : .clockwise, + cullMode: descriptor.backfaceCulling ? .back : .none + ) + let depth = descriptor.depthStencilDescriptor.flatMap { depth -> RenderDepthState? in + guard let format = descriptor.depthPixelFormat.sagaFormat else { return nil } + let stencil = depth.stencilOperationDescriptor.map { op in + let face = RenderStencilFaceState( + compare: op.compare.saga, fail: op.fail.saga, depthFail: op.depthFail.saga, pass: op.pass.saga + ) + return RenderStencilState(front: face, back: face) + } + return RenderDepthState( + format: format, + compare: depth.isDepthTestEnabled ? depth.depthCompareOperator.saga : .always, + writeEnabled: depth.isDepthWriteEnabled, + stencil: stencil + ) + } + + let slot = self.slot + context.queue.enqueue { device in + let pipeline = try await device.createRenderPipeline( + vertex: vertexStage, + fragment: fragmentStage, + colorFormat: colorTargets.first?.format, + vertexBuffers: layouts, + depthState: depth, + primitive: primitive, + colorState: colorTargets.first?.colorState ?? .init(), + additionalColorTargets: Dictionary( + uniqueKeysWithValues: colorTargets.enumerated().dropFirst().map { (UInt32($0.offset), $0.element) } + ) + ) + slot.fulfill(pipeline) + } + } + + private static func vertexLayouts(for descriptor: RenderPipelineDescriptor) -> [VertexBufferLayout] { + let vertexDescriptor = descriptor.vertexDescriptor + return vertexDescriptor.activeBufferIndices.map { bufferIndex in + let attributes = vertexDescriptor.attributes.buffer.enumerated().compactMap { location, attribute -> VertexAttribute? in + guard attribute.format != .invalid, attribute.bufferIndex == bufferIndex else { return nil } + return VertexAttribute(location: UInt32(location), offset: UInt32(attribute.offset), format: attribute.format.saga) + } + // A fallback attribute buffer holds one constant record shared by every vertex. + let isConstantFallback = vertexDescriptor.attributes.buffer + .filter { $0.format != .invalid && $0.bufferIndex == bufferIndex } + .allSatisfy { $0.name.hasPrefix("default") } + let layout = vertexDescriptor.layouts.buffer[bufferIndex] + return VertexBufferLayout( + stride: isConstantFallback ? 0 : UInt32(layout.stride), + stepMode: layout.stepFunction == .perInstance ? .instance : .vertex, + attributes: attributes + ) + } + } + } + + extension RenderPipelineColorAttachmentDescriptor { + var sagaColorState: RenderColorState { + guard isBlendingEnabled else { return RenderColorState() } + return RenderColorState( + blend: RenderBlendState( + color: RenderBlendComponent( + operation: rgbBlendOperation.saga, + sourceFactor: sourceRGBBlendFactor.saga, + destinationFactor: destinationRGBBlendFactor.saga + ), + alpha: RenderBlendComponent( + operation: alphaBlendOperation.saga, + sourceFactor: sourceAlphaBlendFactor.saga, + destinationFactor: destinationAlphaBlendFactor.saga + ) + ) + ) + } + } + + extension IndexPrimitive { + fileprivate var saga: RenderPrimitiveState.Topology { + switch self { + case .triangle: .triangleList + case .triangleStrip: .triangleStrip + case .line: .lineList + case .lineStrip: .lineStrip + case .points: .pointList + } + } + } + + extension VertexFormat { + fileprivate var saga: SAGATypes.VertexFormat { + switch self { + case .invalid, .float: .float32 + case .vector2: .float32x2 + case .vector3: .float32x3 + case .vector4: .float32x4 + case .uint, .char, .short: .uint32 + case .int: .sint32 + } + } + } + + extension BlendFactor { + fileprivate var saga: RenderBlendFactor { + switch self { + case .zero: .zero + case .one: .one + case .sourceColor: .sourceColor + case .oneMinusSourceColor: .oneMinusSourceColor + case .sourceAlpha: .sourceAlpha + case .oneMinusSourceAlpha: .oneMinusSourceAlpha + case .destinationColor: .destinationColor + case .oneMinusDestinationColor: .oneMinusDestinationColor + case .destinationAlpha: .destinationAlpha + case .oneMinusDestinationAlpha: .oneMinusDestinationAlpha + case .sourceAlphaSaturated: .sourceAlphaSaturated + case .blendColor, .blendAlpha: .constant + case .oneMinusBlendColor, .oneMinusBlendAlpha: .oneMinusConstant + } + } + } + + extension BlendOperation { + fileprivate var saga: RenderBlendOperation { + switch self { + case .add: .add + case .subtract: .subtract + case .reverseSubtract: .reverseSubtract + case .min: .min + case .max: .max + } + } + } + + extension CompareOperation { + fileprivate var saga: CompareFunction { + switch self { + case .never: .never + case .less: .less + case .equal: .equal + case .lessOrEqual: .lessEqual + case .greater: .greater + case .notEqual: .notEqual + case .greaterOrEqual: .greaterEqual + case .always: .always + } + } + } + + extension StencilOperation { + fileprivate var saga: RenderStencilOperation { + switch self { + case .keep: .keep + case .zero: .zero + case .replace: .replace + case .incrementAndClamp: .incrementClamp + case .decrementAndClamp: .decrementClamp + case .invert: .invert + case .incrementAndWrap: .incrementWrap + case .decrementAndWrap: .decrementWrap + } + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGASampler.swift b/Sources/AdaRender/Backends/SAGA/SAGASampler.swift new file mode 100644 index 000000000..9b19bd640 --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGASampler.swift @@ -0,0 +1,75 @@ +// +// SAGASampler.swift +// AdaEngine +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import SAGA + import SAGACore + import SAGATypes + + @_spi(Internal) + public final class SAGASampler: Sampler, @unchecked Sendable { + public let descriptor: SamplerDescriptor + let context: SAGAContext + let slot: ResourceSlot + + init(context: SAGAContext, descriptor: SamplerDescriptor) { + self.context = context + self.descriptor = descriptor + self.slot = context.createSampler(descriptor.sagaDescriptor) + } + + deinit { + let slot = self.slot + context.queue.enqueue { device in + if let id = try? slot.get() { + try await device.destroySampler(id) + } + } + } + } + + extension SamplerDescriptor { + var sagaDescriptor: SAGATypes.SamplerDescriptor { + SAGATypes.SamplerDescriptor( + addressModeU: addressModeU.saga, + addressModeV: addressModeV.saga, + addressModeW: addressModeW.saga, + minFilter: minFilter.saga, + magFilter: magFilter.saga, + mipmapFilter: mipFilter.sagaMip, + lodMinClamp: lodMinClamp, + lodMaxClamp: mipFilter == .notMipmapped ? 0 : lodMaxClamp + ) + } + } + + extension SamplerAddressMode { + fileprivate var saga: SAGATypes.SamplerAddressMode { + switch self { + case .clampToEdge: .clampToEdge + case .repeat: .repeat + case .mirroredRepeat: .mirrorRepeat + } + } + } + + extension SamplerMinMagFilter { + fileprivate var saga: SamplerFilter { + switch self { + case .nearest: .nearest + case .linear: .linear + } + } + } + + extension SamplerMipFilter { + fileprivate var sagaMip: SamplerFilter { + switch self { + case .linear: .linear + case .nearest, .notMipmapped: .nearest + } + } + } +#endif diff --git a/Sources/AdaRender/Backends/SAGA/SAGAWindow.swift b/Sources/AdaRender/Backends/SAGA/SAGAWindow.swift new file mode 100644 index 000000000..42bc7910d --- /dev/null +++ b/Sources/AdaRender/Backends/SAGA/SAGAWindow.swift @@ -0,0 +1,148 @@ +// +// SAGAWindow.swift +// AdaEngine +// + +#if SAGA_RENDER && canImport(SAGACompiler) + import Math + import SAGA + import SAGACore + import SAGATypes + import Synchronization + + /// Hands out the windows of a SAGA render backend to its device. + protocol SAGAWindowProvider: AnyObject { + func window(for id: WindowID) -> SAGAWindow? + } + + /// A window is an offscreen color texture that is copied to the canvas on `present`. + final class SAGAWindow: @unchecked Sendable { + let context: SAGAContext + let windowId: WindowID + let pixelFormat: PixelFormat + /// The presentation surface of this window. Windows beyond the first receive theirs from the backend. + let surface = ResourceSlot() + private struct State { + var size: SizeInt + var scaleFactor: Float + var backbuffer: SAGAGPUTexture? + var lastPresentation: SubmissionTicket? + var isRetired = false + } + + private let state: Mutex + + init(context: SAGAContext, windowId: WindowID, size: SizeInt, scaleFactor: Float, pixelFormat: PixelFormat, surfaceID: UInt32? = 0) { + self.context = context + self.windowId = windowId + self.pixelFormat = pixelFormat + if let surfaceID { surface.fulfill(surfaceID) } + self.state = Mutex(State(size: size, scaleFactor: scaleFactor, backbuffer: nil)) + } + + var renderWindow: RenderWindow { + state.withLock { + RenderWindow(windowId: windowId, height: $0.size.height, width: $0.size.width, scaleFactor: $0.scaleFactor) + } + } + + func resize(to size: SizeInt, scaleFactor: Float? = nil) { + state.withLock { + $0.size = size + if let scaleFactor { $0.scaleFactor = scaleFactor } + $0.backbuffer = nil + } + } + + /// Returns the texture for the current frame, creating it after a resize. + func currentBackbuffer() -> SAGAGPUTexture? { + state.withLock { state in + guard !state.isRetired else { + return nil + } + if let backbuffer = state.backbuffer { + return backbuffer + } + let width = max(1, Int(Float(state.size.width) * state.scaleFactor)) + let height = max(1, Int(Float(state.size.height) * state.scaleFactor)) + let descriptor = TextureDescriptor( + width: width, + height: height, + pixelFormat: pixelFormat, + textureUsage: [.renderTarget, .read], + textureType: .texture2D, + debugLabel: "Window backbuffer" + ) + state.backbuffer = try? SAGAGPUTexture(context: context, descriptor: descriptor) + return state.backbuffer + } + } + + func present(_ backbuffer: SAGAGPUTexture) { + state.withLock { state in + guard !state.isRetired else { + return + } + context.queue.enqueue { [self] device in + let ticket = try await device.present(try backbuffer.slot.get(), surface: try surface.get()) + self.state.withLock { $0.lastPresentation = ticket } + } + } + } + + func retire(_ removeSurface: @escaping @Sendable (UInt32) async -> Void) { + state.withLock { state in + guard !state.isRetired else { + return + } + state.isRetired = true + context.queue.enqueueCleanup { [self] in + guard let id = try? surface.get() else { + return + } + let ticket = self.state.withLock { $0.lastPresentation } + do { + try await ticket?.wait() + } catch { + await removeSurface(id) + throw error + } + await removeSurface(id) + } + } + } + } + + final class SAGASwapchain: Swapchain, @unchecked Sendable { + let window: SAGAWindow + + init(window: SAGAWindow) { + self.window = window + } + + var drawablePixelFormat: PixelFormat { window.pixelFormat } + + func getNextDrawable(_: RenderDevice) -> (any Drawable)? { + guard let texture = window.currentBackbuffer() else { + return nil + } + return SAGADrawable(texture: texture, window: window) + } + } + + final class SAGADrawable: Drawable, @unchecked Sendable { + let texture: any GPUTexture + private let backbuffer: SAGAGPUTexture + private let window: SAGAWindow + + init(texture: SAGAGPUTexture, window: SAGAWindow) { + self.texture = texture + self.backbuffer = texture + self.window = window + } + + func present() throws { + window.present(backbuffer) + } + } +#endif diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift index 64e04d294..993408cd0 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderPipeline.swift @@ -279,7 +279,10 @@ device.createBindGroupLayout( descriptor: WebGPU.GPUBindGroupLayoutDescriptor( label: "\(descriptor.debugName) Bind Group Layout \(index)", - entries: makeBindGroupLayoutEntries(from: descriptorSet) + entries: makeBindGroupLayoutEntries( + from: descriptorSet, + unfilterableTextureBindings: descriptor.unfilterableTextureBindings[index] ?? [] + ) ) ) } @@ -302,7 +305,8 @@ } private static func makeBindGroupLayoutEntries( - from descriptorSet: ShaderResource.DescriptorSet + from descriptorSet: ShaderResource.DescriptorSet, + unfilterableTextureBindings: Set ) -> [WebGPU.GPUBindGroupLayoutEntry] { var entries: [WebGPU.GPUBindGroupLayoutEntry] = [] @@ -317,12 +321,18 @@ } for (binding, texture) in descriptorSet.sampledImages { + let sampleType: WebGPU.GPUTextureSampleType + if texture.isDepth == true { + sampleType = .depth + } else { + sampleType = unfilterableTextureBindings.contains(binding) ? .unfilterableFloat : .float + } entries.append( WebGPU.GPUBindGroupLayoutEntry( binding: binding, visibility: texture.shaderStage.toWebGPU, texture: WebGPU.GPUTextureBindingLayout( - sampleType: .float, + sampleType: sampleType, viewDimension: texture.textureType.toWebGPUTextureViewDimension, multisampled: false ) diff --git a/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift b/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift index 0ded92950..3dc8e244f 100644 --- a/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift +++ b/Sources/AdaRender/Backends/WebGPU/WebGPURenderDevice.swift @@ -64,11 +64,18 @@ WGPUVisibilityProcessor(context: context) } + // The browser build keeps CPU-culled shadow lists: the compute processor traps in WASM. + #if WASM + public var supportsGPUShadowCull: Bool { false } + + public func createGPUShadowCullProcessor() -> (any GPUShadowCullProcessor)? { nil } + #else public var supportsGPUShadowCull: Bool { true } public func createGPUShadowCullProcessor() -> (any GPUShadowCullProcessor)? { WGPUShadowCullProcessor(context: context) } + #endif /// Native only: the draws read GPU-written counts and select instances through `firstInstance`. /// `ADAENGINE_SHADOW_MESHLETS=0` keeps whole-part shadow culling. diff --git a/Sources/AdaRender/Materials/Material.swift b/Sources/AdaRender/Materials/Material.swift index bcf7d7430..4a9a524cd 100644 --- a/Sources/AdaRender/Materials/Material.swift +++ b/Sources/AdaRender/Materials/Material.swift @@ -7,6 +7,7 @@ import AdaAssets import AdaUtils +import Synchronization // TODO: (Vlad) I think that `MaterialStorage` isn't great solution. @@ -23,17 +24,38 @@ public class Material: Asset, Hashable, @unchecked Sendable { public let shaderSource: ShaderSource + private let linkedShaderModule: ShaderModule? + + /// Builds a complete module for one set of feature defines (for example WESL variants chosen by vertex layout). + typealias VariantModuleFactory = @Sendable ([ShaderDefine]) throws -> ShaderModule + private let variantModuleFactory: VariantModuleFactory? + private let variantModules = Mutex<[[ShaderDefine]: ShaderModule]>([:]) + /// Create a new material from shader source. public init(shaderSource: ShaderSource) { self.shaderSource = shaderSource + self.linkedShaderModule = nil + self.variantModuleFactory = nil + } + + /// Internal complete-module path. Its identity must not alias an empty legacy source. + init(shaderSource: ShaderSource, linkedShaderModule: ShaderModule?, variantModuleFactory: VariantModuleFactory? = nil) { + self.shaderSource = shaderSource + self.linkedShaderModule = linkedShaderModule + self.variantModuleFactory = variantModuleFactory } + private var isLinked: Bool { linkedShaderModule != nil || variantModuleFactory != nil } + public required convenience init(from assetDecoder: AssetDecoder) throws { let shaderSource = try ShaderSource(from: assetDecoder) self.init(shaderSource: shaderSource) } public func encodeContents(with encoder: AssetEncoder) throws { + guard !isLinked else { + throw AssetError.message("Linked-module materials require their original WESL asset and cannot encode empty shader sources.") + } try self.shaderSource.encodeContents(with: encoder) } @@ -66,6 +88,19 @@ public class Material: Asset, Hashable, @unchecked Sendable { /// Supplies shader stages for a material, including materials loaded directly from runtime sources. open func makeShaderModule(defines: [ShaderDefine]) throws -> ShaderModule { + if let variantModuleFactory { + let key = defines.sorted { $0.name < $1.name } + if let cached = variantModules.withLock({ $0[key] }) { return cached } + let module = try variantModuleFactory(key) + variantModules.withLock { $0[key] = module } + return module + } + if let linkedShaderModule { + guard defines.isEmpty else { + throw AssetError.message("Linked-module materials must select feature variants before construction.") + } + return linkedShaderModule + } let compiler = ShaderCompiler(shaderSource: shaderSource) for define in defines { compiler.setMacro(define.name, value: define.value, for: .vertex) @@ -77,11 +112,18 @@ public class Material: Asset, Hashable, @unchecked Sendable { // MARK: Hashable public static func == (lhs: Material, rhs: Material) -> Bool { - lhs.shaderSource == rhs.shaderSource + if lhs.isLinked || rhs.isLinked { + return lhs === rhs + } + return lhs.shaderSource == rhs.shaderSource } public func hash(into hasher: inout Hasher) { - hasher.combine(self.shaderSource) + if isLinked { + hasher.combine(ObjectIdentifier(self)) + } else { + hasher.combine(self.shaderSource) + } } // MARK: Mesh diff --git a/Sources/AdaRender/Materials/ReflectedMaterial/CanvasMaterial.swift b/Sources/AdaRender/Materials/ReflectedMaterial/CanvasMaterial.swift index 70b2658c1..2ef441c44 100644 --- a/Sources/AdaRender/Materials/ReflectedMaterial/CanvasMaterial.swift +++ b/Sources/AdaRender/Materials/ReflectedMaterial/CanvasMaterial.swift @@ -32,7 +32,22 @@ private func loadBundledCanvasShaderSource( /// This material can be render Meshes in 2D world. public protocol CanvasMaterial: ReflectedMaterial {} +private func canvasVariantModule(at path: String, defines: [ShaderDefine]) throws -> ShaderModule { + try ShaderModule.createBundled(at: path, from: Bundle.adaModule, defines: defines.map { $0.name }) +} + extension CanvasMaterial { + // The browser keeps the staged GLSL variants until the exporter prelinks these WESL variants. + #if WASM + public static var usesVariantShaderModules: Bool { false } + #else + public static var usesVariantShaderModules: Bool { ShaderModule.supportsBundledWESL } + #endif + + public static func variantShaderModule(defines: [ShaderDefine]) throws -> ShaderModule { + try canvasVariantModule(at: "Shaders/mesh2d/mesh2d.wesl", defines: defines) + } + public static func vertexShader() throws -> AssetHandle { return try loadBundledCanvasShaderSource( at: "Shaders/mesh2d/mesh2d.glsl#vert", @@ -104,6 +119,10 @@ public struct ColorCanvasMaterial: CanvasMaterial { self.color = color } + public static func variantShaderModule(defines: [ShaderDefine]) throws -> ShaderModule { + try canvasVariantModule(at: "Shaders/Materials/color_canvas_material.wesl", defines: defines) + } + public static func fragmentShader() throws -> AssetHandle { return try loadBundledCanvasShaderSource( at: "Shaders/Materials/color_canvas_material.glsl", @@ -123,6 +142,10 @@ struct CircleCanvasMaterial: CanvasMaterial { @Uniform(binding: 0, propertyName: "u_Color") var color: Color + public static func variantShaderModule(defines: [ShaderDefine]) throws -> ShaderModule { + try canvasVariantModule(at: "Shaders/Materials/circle_canvas_material.wesl", defines: defines) + } + public static func fragmentShader() throws -> AssetHandle { return try loadBundledCanvasShaderSource( at: "Shaders/Materials/circle_canvas_material.glsl", diff --git a/Sources/AdaRender/Materials/ReflectedMaterial/CustomMaterial.swift b/Sources/AdaRender/Materials/ReflectedMaterial/CustomMaterial.swift index 191b4566b..7e613d978 100644 --- a/Sources/AdaRender/Materials/ReflectedMaterial/CustomMaterial.swift +++ b/Sources/AdaRender/Materials/ReflectedMaterial/CustomMaterial.swift @@ -118,6 +118,7 @@ public final class CustomMaterial: Material, MaterialValue /// Contains all bindable properties founded in passed ``ReflectedMaterial``. private var bindableValues: [_ShaderBindProperty] = [] + private let sourceLoadError: (any Error)? public convenience init(wrappedValue: T) { self.init(wrappedValue) @@ -128,41 +129,51 @@ public final class CustomMaterial: Material, MaterialValue self.material = material let shaderSource = ShaderSource() + var linkedModule: ShaderModule? + var sourceLoadError: (any Error)? + var variantFactory: Material.VariantModuleFactory? + if T.usesVariantShaderModules { + variantFactory = { @Sendable defines in try T.variantShaderModule(defines: defines) } + } do { - let vertexShaderSource = try T.vertexShader() - let fragmentShaderSource = try T.fragmentShader() - - guard let vertexSource = vertexShaderSource.asset.getSource(for: .vertex) else { - throw CustomMaterialError.missingShaderStage(.vertex, material: String(reflecting: T.self)) - } + linkedModule = try T.shaderModule() + if linkedModule == nil && variantFactory == nil { + let vertexShaderSource = try T.vertexShader() + let fragmentShaderSource = try T.fragmentShader() - guard let fragmentSource = fragmentShaderSource.asset.getSource(for: .fragment) else { - throw CustomMaterialError.missingShaderStage(.fragment, material: String(reflecting: T.self)) - } + guard let vertexSource = vertexShaderSource.asset.getSource(for: .vertex) else { + throw CustomMaterialError.missingShaderStage(.vertex, material: String(reflecting: T.self)) + } - shaderSource.setSource( - vertexSource, - for: .vertex, - fileURL: vertexShaderSource.asset.getSourceFileURL(for: .vertex) - ) - shaderSource.setSource( - fragmentSource, - for: .fragment, - fileURL: fragmentShaderSource.asset.getSourceFileURL(for: .fragment) - ) + guard let fragmentSource = fragmentShaderSource.asset.getSource(for: .fragment) else { + throw CustomMaterialError.missingShaderStage(.fragment, material: String(reflecting: T.self)) + } - shaderSource.includeSearchPaths.append(contentsOf: vertexShaderSource.asset.includeSearchPaths) - shaderSource.includeSearchPaths.append(contentsOf: fragmentShaderSource.asset.includeSearchPaths) + shaderSource.setSource( + vertexSource, + for: .vertex, + fileURL: vertexShaderSource.asset.getSourceFileURL(for: .vertex) + ) + shaderSource.setSource( + fragmentSource, + for: .fragment, + fileURL: fragmentShaderSource.asset.getSourceFileURL(for: .fragment) + ) + + shaderSource.includeSearchPaths.append(contentsOf: vertexShaderSource.asset.includeSearchPaths) + shaderSource.includeSearchPaths.append(contentsOf: fragmentShaderSource.asset.includeSearchPaths) + } } catch { - assertionFailure("[CustomMaterial] \(error)") + sourceLoadError = error } shaderSource.fileURL = try? ShaderCache .getCacheDirectory() .appending(path: String(reflecting: type(of: material)), directoryHint: .isDirectory) - super.init(shaderSource: shaderSource) + self.sourceLoadError = sourceLoadError + super.init(shaderSource: shaderSource, linkedShaderModule: linkedModule, variantModuleFactory: variantFactory) self.reflectMaterial(from: material) } @@ -180,6 +191,11 @@ public final class CustomMaterial: Material, MaterialValue } } + override public func makeShaderModule(defines: [ShaderDefine]) throws -> ShaderModule { + if let sourceLoadError { throw sourceLoadError } + return try super.makeShaderModule(defines: defines) + } + // MARK: - Mesh override public func collectDefines(for vertexDescriptor: VertexDescriptor, keys: Set) -> [ShaderDefine] { diff --git a/Sources/AdaRender/Materials/ReflectedMaterial/ReflectedMaterial.swift b/Sources/AdaRender/Materials/ReflectedMaterial/ReflectedMaterial.swift index 6ac692699..1c744e4db 100644 --- a/Sources/AdaRender/Materials/ReflectedMaterial/ReflectedMaterial.swift +++ b/Sources/AdaRender/Materials/ReflectedMaterial/ReflectedMaterial.swift @@ -32,6 +32,17 @@ import AdaAssets /// When you declared material struct, you can pass it to the ``CustomMaterial`` object where main magic happens. /// public protocol ReflectedMaterial: ShaderBindable { + /// Optional complete module for materials whose stages are linked together (for example WESL). + /// Return nil to use the legacy stage-source path. Macro variants are not supported by this hook. + static func shaderModule() throws -> ShaderModule? + + /// Whether the material links a complete module per feature variant. Variants are chosen from + /// ``configureShaderDefines(keys:vertexDescriptor:)``, so vertex layout can select WESL conditional code. + static var usesVariantShaderModules: Bool { get } + + /// Builds the complete module for one set of feature defines when ``usesVariantShaderModules`` is true. + static func variantShaderModule(defines: [ShaderDefine]) throws -> ShaderModule + /// Configure and pass shader source for custom material. /// - Returns: A shader sources for vertex shader. static func vertexShader() throws -> AssetHandle @@ -64,3 +75,11 @@ public protocol ReflectedMaterial: ShaderBindable { vertexDescriptor: VertexDescriptor ) throws -> RenderPipelineDescriptor } + +extension ReflectedMaterial { + public static func shaderModule() throws -> ShaderModule? { nil } + public static var usesVariantShaderModules: Bool { false } + public static func variantShaderModule(defines _: [ShaderDefine]) throws -> ShaderModule { + throw AssetError.message("\(String(reflecting: self)) does not provide variant shader modules.") + } +} diff --git a/Sources/AdaRender/Materials/RuntimeWGSLMaterial.swift b/Sources/AdaRender/Materials/RuntimeWGSLMaterial.swift index 693402e9d..a35f5ad97 100644 --- a/Sources/AdaRender/Materials/RuntimeWGSLMaterial.swift +++ b/Sources/AdaRender/Materials/RuntimeWGSLMaterial.swift @@ -3,7 +3,7 @@ import AdaUtils import Foundation import Math -/// Source-backed WebGPU UI material. ABI 1 reserves group 0/binding 2 for the engine view transform; +/// Source-backed WGSL UI material. ABI 1 reserves group 0/binding 2 for the engine view transform; /// group 1 contains `parameters: vec4` at 0, `image` at 1 and `imageSampler` at 2. /// Parameter state is protected by a lock; GPU updates follow the existing material render lifecycle. public final class RuntimeWGSLMaterial: Material, @unchecked Sendable { @@ -41,8 +41,8 @@ public final class RuntimeWGSLMaterial: Material, @unchecked Sendable { if let module { return module } - guard unsafe RenderEngine.shared.type == .webgpu else { - throw RuntimeMaterialError.webGPURequired + guard unsafe RenderEngine.shared.type.consumesWGSL else { + throw RuntimeMaterialError.wgslRendererRequired } var vertexReflection = ShaderReflectionData() let view = ShaderResource.ShaderBuffer(name: "view", size: 192, shaderStage: .vertex, binding: 2, resourceAccess: .read, members: [:]) @@ -96,5 +96,5 @@ public final class RuntimeWGSLMaterial: Material, @unchecked Sendable { } """ - private enum RuntimeMaterialError: Error { case explicitSourcesRequired, webGPURequired } + private enum RuntimeMaterialError: Error { case explicitSourcesRequired, wgslRendererRequired } } diff --git a/Sources/AdaRender/Misc/RenderPipeline.swift b/Sources/AdaRender/Misc/RenderPipeline.swift index c703be8df..7a4a12cc5 100644 --- a/Sources/AdaRender/Misc/RenderPipeline.swift +++ b/Sources/AdaRender/Misc/RenderPipeline.swift @@ -253,6 +253,9 @@ public struct RenderPipelineDescriptor: Sendable { /// The color attachments. public var colorAttachments: [RenderPipelineColorAttachmentDescriptor] = [] + /// Texture bindings that are read without filtering, keyed by bind group and slot. + package var unfilterableTextureBindings: [Int: Set] = [:] + /// Initialize a new render pipeline descriptor. /// /// - Parameter vertex: The vertex shader. diff --git a/Sources/AdaRender/RenderEngine.swift b/Sources/AdaRender/RenderEngine.swift index 75cff51ee..bbb4d97df 100644 --- a/Sources/AdaRender/RenderEngine.swift +++ b/Sources/AdaRender/RenderEngine.swift @@ -7,6 +7,9 @@ import AdaUtils import Foundation +#if SAGA_WEBGPU && canImport(JavaScriptKit) +import JavaScriptKit +#endif import Math import OrderedCollections @@ -98,12 +101,30 @@ public final class RenderEngine: RenderBackend, Sendable { @MainActor public func resizeWindow(_ windowId: WindowID, newSize: SizeInt, scaleFactor: Float) throws { + #if SAGA_WEBGPU && canImport(SAGAWebHost) && canImport(JavaScriptKit) && canImport(SAGACompiler) + if let sagaBackend = self.renderBackend as? SAGARenderBackend { + try sagaBackend.resizeWindow(windowId, newSize: newSize, scaleFactor: scaleFactor) + return + } + #endif + #if SAGA_RENDER && SAGA_NATIVE && (os(Windows) || os(Linux) || os(Android)) && canImport(SAGAVulkan) && canImport(SAGACompiler) + if let sagaBackend = self.renderBackend as? SAGANativeRenderBackend { + try sagaBackend.resizeWindow(windowId, newSize: newSize, scaleFactor: scaleFactor) + return + } + #endif #if WEBGPU_ENABLED && canImport(WebGPU) if let webGPUBackend = self.renderBackend as? WebGPURenderBackend { try webGPUBackend.resizeWindow(windowId, newSize: newSize, scaleFactor: scaleFactor) return } #endif + #if SAGA_METAL && canImport(SAGAMetal) + if let sagaBackend = self.renderBackend as? SAGAMetalRenderBackend { + try sagaBackend.resizeWindow(windowId, newSize: newSize, scaleFactor: scaleFactor) + return + } + #endif try self.renderBackend.resizeWindow(windowId, newSize: newSize) } @@ -138,16 +159,37 @@ extension RenderEngine { let renderBackend: RenderBackend switch preferredBackend { case .webgpu: - #if WEBGPU_ENABLED && canImport(WebGPU) - renderBackend = try WebGPURenderBackend.createBackend() - #else - #if os(Android) - throw RenderEngineSetupError.androidWebGPUBackendUnavailable - #elseif WASM - throw RenderEngineSetupError.browserWebGPUBackendUnavailable + #if WASM + #if SAGA_WEBGPU && canImport(SAGAWebHost) && canImport(JavaScriptKit) && canImport(SAGACompiler) + // The browser renders through SAGA when the page opened its WebGPU host (`__adaSAGAHost`). + if JSObject.global.__adaSAGAHost.object != nil { + renderBackend = try SAGARenderBackend.createBackend() + } else { + #if WEBGPU_ENABLED && canImport(WebGPU) + renderBackend = try WebGPURenderBackend.createBackend() + #else + throw RenderEngineSetupError.browserWebGPUBackendUnavailable + #endif + } + #elseif WEBGPU_ENABLED && canImport(WebGPU) + renderBackend = try WebGPURenderBackend.createBackend() #else - fallthrough + throw RenderEngineSetupError.browserWebGPUBackendUnavailable #endif + #elseif WEBGPU_ENABLED && canImport(WebGPU) + renderBackend = try WebGPURenderBackend.createBackend() + #elseif os(Android) + throw RenderEngineSetupError.androidWebGPUBackendUnavailable + #else + fallthrough + #endif + case .saga: + #if SAGA_METAL && canImport(SAGAMetal) + renderBackend = try SAGAMetalRenderBackend() + #elseif SAGA_RENDER && SAGA_NATIVE && (os(Windows) || os(Linux) || os(Android)) && canImport(SAGAVulkan) && canImport(SAGACompiler) + renderBackend = try SAGANativeRenderBackend() + #else + fallthrough #endif case .metal: #if METAL @@ -163,10 +205,14 @@ extension RenderEngine { } private static func defaultBackendType() -> RenderBackendType { - #if os(Android) + #if SAGA_METAL && canImport(SAGAMetal) + return .saga + #elseif SAGA_RENDER && SAGA_NATIVE && canImport(SAGAVulkan) + return .saga + #elseif os(Android) return .webgpu #elseif WASM - #if WEBGPU_ENABLED && canImport(WebGPU) + #if WEBGPU_ENABLED return .webgpu #else return .headless diff --git a/Sources/AdaRender/RenderSurface.swift b/Sources/AdaRender/RenderSurface.swift index bfe5b8121..698b334ff 100644 --- a/Sources/AdaRender/RenderSurface.swift +++ b/Sources/AdaRender/RenderSurface.swift @@ -25,6 +25,7 @@ public protocol RenderSurface { public protocol LinuxXlibRenderSurface: RenderSurface { var xlibDisplay: UnsafeMutableRawPointer { get } var xlibWindow: UInt64 { get } + var xlibWindowLifetime: any Sendable { get } } #endif @@ -104,10 +105,11 @@ public protocol RenderSurface { #endif #if os(Android) - /// Android native window retained for the lifetime of its WebGPU surface. + /// Android native window retained for the lifetime of its GPU surface. @MainActor public protocol AndroidNativeWindowRenderSurface: RenderSurface { var nativeWindow: UnsafeMutableRawPointer { get } + var nativeWindowLifetime: any Sendable { get } } #endif diff --git a/Sources/AdaRender/Shaders/Graph/ShaderGraphArchive.swift b/Sources/AdaRender/Shaders/Graph/ShaderGraphArchive.swift index 0e3550d16..1d23cae59 100644 --- a/Sources/AdaRender/Shaders/Graph/ShaderGraphArchive.swift +++ b/Sources/AdaRender/Shaders/Graph/ShaderGraphArchive.swift @@ -18,15 +18,24 @@ public struct ShaderGraphArchive: Codable, Sendable { self.wgsl = wgsl self.reflection = reflection } + + /// Identifies the baked fragment by template name and the graph's generated WGSL. + public static func identity(template: String, graphSource: String) -> String { + template + "\n" + graphSource + } } + /// Version 2 fragments are linked from the WESL templates and identified by ``Fragment/identity(template:graphSource:)``. + /// Version 1 archives were baked from GLSL templates and cannot be used. + public static let currentVersion = 2 + public var version: Int public var graphs: [String: ShaderGraph] public var fragments: [Fragment] public var texturePaths: [String] public init(graphs: [String: ShaderGraph], fragments: [Fragment], texturePaths: [String]) { - version = 1 + version = Self.currentVersion self.graphs = graphs self.fragments = fragments self.texturePaths = texturePaths @@ -52,7 +61,11 @@ public final class ShaderGraphRuntimeLibrary: @unchecked Sendable { private var textures: [String: Texture2D] = [:] public static func install(_ archive: ShaderGraphArchive) throws { - guard archive.version == 1 else { throw ShaderGraphDiagnostic(code: .unsupportedVersion) } + guard archive.version == ShaderGraphArchive.currentVersion else { + throw ShaderGraphDiagnostic( + code: .unsupportedVersion, + nodeID: "Shader Graph archive version \(archive.version) was baked from GLSL templates; rebuild it with AdaShaderGraphBaker (version \(ShaderGraphArchive.currentVersion))") + } for graph in archive.graphs.values { _ = try graph.lowered() } guard archive.fragments.allSatisfy({ !$0.source.isEmpty && !$0.wgsl.isEmpty && !$0.entryPoint.isEmpty }) else { throw ShaderGraphDiagnostic(code: .invalidReference) diff --git a/Sources/AdaRender/Shaders/Graph/ShaderGraphMaterial.swift b/Sources/AdaRender/Shaders/Graph/ShaderGraphMaterial.swift index 348d28d7b..62ac0fc67 100644 --- a/Sources/AdaRender/Shaders/Graph/ShaderGraphMaterial.swift +++ b/Sources/AdaRender/Shaders/Graph/ShaderGraphMaterial.swift @@ -39,6 +39,9 @@ public struct ShaderGraphMaterialSource: Codable, Hashable, Sendable { /// are replaced rather than overwritten, preserving values used by in-flight commands. public final class ShaderGraphMaterial: Codable, @unchecked Sendable { public let program: ShaderGraphProgram + /// The authored graph, retained so the WGSL form can be generated for WESL templates. + let graph: ShaderGraph + private var cachedWGSLProgram: ShaderGraphProgram? let pipelineStorage = ShaderGraphPipelineStorage() private let lock = NSLock() private var source: ShaderGraphMaterialSource @@ -115,6 +118,7 @@ public final class ShaderGraphMaterial: Codable, @unchecked Sendable { throw ShaderGraphDiagnostic(code: .invalidReference) } program = try graph.compileGLSL() + self.graph = graph self.source = source for (id, value) in source.values { try validate(value, for: id) } for id in source.textures.keys where !program.textures.contains(where: { $0.parameterID == id }) { @@ -135,14 +139,15 @@ public final class ShaderGraphMaterial: Codable, @unchecked Sendable { public func makeInstance() -> ShaderGraphMaterial { lock.lock() defer { lock.unlock() } - let instance = ShaderGraphMaterial(program: program, source: source, textures: loadedTextures, resourceBasePath: resourceBasePath, resourceRootPath: resourceRootPath) + let instance = ShaderGraphMaterial(program: program, graph: graph, source: source, textures: loadedTextures, resourceBasePath: resourceBasePath, resourceRootPath: resourceRootPath) instance.time = time instance.automaticTime = automaticTime return instance } - private init(program: ShaderGraphProgram, source: ShaderGraphMaterialSource, textures: [Int: Texture2D], resourceBasePath: String?, resourceRootPath: String?) { + private init(program: ShaderGraphProgram, graph: ShaderGraph, source: ShaderGraphMaterialSource, textures: [Int: Texture2D], resourceBasePath: String?, resourceRootPath: String?) { self.program = program + self.graph = graph self.source = source self.loadedTextures = textures self.resourceBasePath = resourceBasePath @@ -153,6 +158,22 @@ public final class ShaderGraphMaterial: Codable, @unchecked Sendable { try descriptor.encode(to: encoder) } + /// The same graph as WGSL for WESL templates. Parameter slots and bindings equal ``program``'s, so a material binds + /// identically whichever form a pipeline compiles. + func wgslProgram() throws -> ShaderGraphProgram { + lock.lock() + if let cachedWGSLProgram { + lock.unlock() + return cachedWGSLProgram + } + lock.unlock() + let compiled = try graph.compileWGSL() + lock.lock() + cachedWGSLProgram = compiled + lock.unlock() + return compiled + } + public func value(for id: String) -> [Float]? { lock.lock() defer { lock.unlock() } diff --git a/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+Validation.swift b/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+Validation.swift index f1dfae188..57ada5d12 100644 --- a/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+Validation.swift +++ b/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+Validation.swift @@ -32,6 +32,9 @@ extension ShaderGraphProgram { /// Preserve virtual source numbers when composing a renderer's fragment template. public func inserting(in template: String) -> String { + if format == .wgsl { + return template.replacingOccurrences(of: "// ADA_GRAPH_SOURCE_MARKER", with: source) + } let marker = "#define ADA_GRAPH_SOURCE_MARKER 1" let line = template.components(separatedBy: marker).first?.filter { $0 == "\n" }.count ?? 0 return template.replacingOccurrences(of: marker, with: "#line 1 101\n" + source + "\n#line \(line + 2) 0") diff --git a/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+WGSL.swift b/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+WGSL.swift new file mode 100644 index 000000000..fe6c7c8c7 --- /dev/null +++ b/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram+WGSL.swift @@ -0,0 +1,134 @@ +extension ShaderGraph.ValueType { + var wgslType: String { + switch self { + case .float: "f32" + case .bool: "bool" + case .vector2: "vec2f" + case .vector3: "vec3f" + case .vector4, .color: "vec4f" + case .texture2D: "texture_2d" + } + } +} + +extension ShaderGraph { + /// Generates WGSL for the same graph, parameter slots and bindings as ``compileGLSL()``, without touching a GPU. + /// + /// The result defines `AdaGraphSurface` and `ada_graph_evaluate(uv: vec2f)`. A WESL template places it before + /// its entry point; resources live in group ``ShaderGraphProgram/resourceSet`` at the documented bindings. + /// User identifiers are never interpolated as shader code. + public func compileWGSL() throws -> ShaderGraphProgram { + let analysis = try analyzed() + let ir = analysis.ir + let parameters = analysis.parameters + let textures = analysis.textures + let textureNames = analysis.textureNames + var lines = [ + "struct AdaGraphUniforms { values: array, };", + "@group(\(ShaderGraphProgram.resourceSet)) @binding(\(ShaderGraphProgram.uniformBinding)) var ada_graph_uniforms: AdaGraphUniforms;" + ] + if ir.instructions.contains(where: { $0.operation == .noise2D }) { + lines += [ + "fn ada_graph_hash(p: vec2u) -> f32 { var h = p.x * 1664525u + p.y * 1013904223u; h = (h ^ (h >> 16u)) * 2246822519u; return f32(h & 16777215u) / 16777215.0; }", + "fn ada_graph_noise(p: vec2f) -> f32 { let i = vec2u(vec2i(floor(p))); var f = fract(p); f = f * f * (vec2f(3.0) - 2.0 * f);", + "return mix(mix(ada_graph_hash(i), ada_graph_hash(i + vec2u(1u, 0u)), f.x), mix(ada_graph_hash(i + vec2u(0u, 1u)), ada_graph_hash(i + vec2u(1u)), f.x), f.y); }" + ] + } + if !textures.isEmpty { + lines.append("@group(\(ShaderGraphProgram.samplerSet)) @binding(\(ShaderGraphProgram.samplerBinding)) var ada_graph_sampler: sampler;") + } + for (index, texture) in textures.enumerated() { + lines.append("@group(\(ShaderGraphProgram.resourceSet)) @binding(\(texture.binding)) var ada_graph_texture\(index): texture_2d;") + } + lines += [ + "struct AdaGraphSurface { color: vec4f, normal: vec3f, metallic: f32, roughness: f32, emission: vec3f, occlusion: f32, alpha: f32, };", + "fn ada_graph_evaluate(ada_graph_uv: vec2f) -> AdaGraphSurface {", + "var ada_graph_result: AdaGraphSurface; ada_graph_result.color = vec4f(1.0); ada_graph_result.normal = vec3f(0.0, 0.0, 1.0);", + "ada_graph_result.metallic = 0.0; ada_graph_result.roughness = 0.5; ada_graph_result.emission = vec3f(0.0); ada_graph_result.occlusion = 1.0; ada_graph_result.alpha = 1.0;" + ] + let names = Dictionary(uniqueKeysWithValues: ir.instructions.enumerated().map { ($1.nodeID, "ada_graph_n\($0)") }) + var locations: [Int: ShaderGraphProgram.Location] = [:] + func input(_ link: ShaderGraphIR.Input) -> String { + let base = textureNames[link.source.node] ?? names[link.source.node] ?? "0.0" + let suffix: String + switch link.source.port { + case "rgb": suffix = ".rgb" + case "alpha": suffix = ".a" + case "x", "y", "z", "w": suffix = "." + link.source.port + default: suffix = "" + } + let value = base + suffix + return link.conversion == .scalarBroadcast ? "\(link.type.wgslType)(\(value))" : value + } + for node in ir.instructions { + let name = names[node.nodeID] ?? "ada_graph_unused" + func argument(_ port: String) -> String { node.inputs[port].map(input) ?? "0.0" } + if node.operation == .output { + for port in node.inputs.keys.sorted() { + locations[lines.count + 1] = .init(nodeID: node.nodeID, port: port) + lines.append("ada_graph_result.\(port == "baseColor" ? "color" : port) = \(argument(port));") + } + continue + } + guard let type = node.type, type != .texture2D else { continue } + let t = type.wgslType + let expression: String + switch node.operation { + case .constant: + expression = type == .bool ? (node.value == [1] ? "true" : "false") + : "\(t)(\(node.value.map(String.init(describing:)).joined(separator: ", ")))" + case .parameter: + let slot = parameters.first { $0.id == node.reference }?.slot ?? 0 + let value = "ada_graph_uniforms.values[\(slot)]" + switch type { + case .bool: expression = "(\(value).x != 0.0)" + case .float: expression = value + ".x" + case .vector2: expression = value + ".xy" + case .vector3: expression = value + ".xyz" + default: expression = value + } + case .uv: expression = "ada_graph_uv" + case .time: expression = "ada_graph_uniforms.values[0].x" + case .spriteTexture: expression = "textureSample(u_Texture, u_Sampler, \(node.inputs["uv"].map(input) ?? "ada_graph_uv"))" + case .add: expression = "(\(argument("a")) + \(argument("b")))" + case .multiply: expression = "(\(argument("a")) * \(argument("b")))" + case .subtract: expression = "(\(argument("a")) - \(argument("b")))" + case .divide: expression = "((\(argument("a")) / max(abs(\(argument("b"))), \(t)(0.000001))) * sign(\(argument("b"))))" + case .power: expression = "pow(max(\(argument("a")), \(t)(0.000001)), \(argument("b")))" + case .minimum: expression = "min(\(argument("a")), \(argument("b")))" + case .maximum: expression = "max(\(argument("a")), \(argument("b")))" + case .clamp: expression = "clamp(\(argument("value")), \(argument("min")), \(argument("max")))" + case .saturate: expression = "clamp(\(argument("value")), \(t)(0.0), \(t)(1.0))" + case .oneMinus: expression = "(\(t)(1.0) - \(argument("value")))" + case .sine: expression = "sin(\(argument("value")))" + case .cosine: expression = "cos(\(argument("value")))" + case .absolute: expression = "abs(\(argument("value")))" + case .fraction: expression = "fract(\(argument("value")))" + case .floor: expression = "floor(\(argument("value")))" + case .step: expression = "step(\(argument("edge")), \(argument("value")))" + case .smoothstep: expression = "smoothstep(\(argument("edge0")), \(argument("edge1")), \(argument("value")))" + case .noise2D: expression = "ada_graph_noise(\(argument("uv")))" + case .combine2: expression = "vec2f(\(argument("x")), \(argument("y")))" + case .combine3: expression = "vec3f(\(argument("x")), \(argument("y")), \(argument("z")))" + case .combine4: expression = "vec4f(\(argument("x")), \(argument("y")), \(argument("z")), \(argument("w")))" + case .select: expression = "select(\(argument("b")), \(argument("a")), \(argument("condition")))" + case .lerp: expression = "mix(\(argument("a")), \(argument("b")), \(argument("t")))" + case .sampleTexture: expression = "textureSample(\(argument("texture")), ada_graph_sampler, \(argument("uv")))" + case .texture, .output: continue + } + locations[lines.count + 1] = .init(nodeID: node.nodeID, port: nil) + lines.append("let \(name): \(t) = \(expression);") + } + lines += ["return ada_graph_result;", "}"] + return ShaderGraphProgram( + target: target, + renderSettings: renderSettings, + source: lines.joined(separator: "\n"), + parameters: parameters, + textures: textures, + sourceMap: locations, + usesTime: ir.instructions.contains { $0.operation == .time }, + format: .wgsl + ) + } +} diff --git a/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram.swift b/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram.swift index 8f011a45c..4dc416007 100644 --- a/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram.swift +++ b/Sources/AdaRender/Shaders/Graph/ShaderGraphProgram.swift @@ -38,6 +38,13 @@ public struct ShaderGraphProgram: Hashable, Sendable { public let textures: [Texture] public let sourceMap: [Int: Location] public let usesTime: Bool + /// The language of ``source``. GLSL stays until every template that embeds graphs is ported to WESL. + public var format: SourceFormat = .glsl + + public enum SourceFormat: String, Hashable, Sendable { + case glsl + case wgsl + } } extension ShaderGraph.ValueType { @@ -54,8 +61,16 @@ extension ShaderGraph.ValueType { } extension ShaderGraph { - /// Generates GLSL without touching a GPU. User identifiers are never interpolated as shader code. - public func compileGLSL() throws -> ShaderGraphProgram { + /// Parameters, texture bindings and texture names derived from the lowered graph. Shared by every source + /// language so slots and bindings never differ between them. + struct Analysis { + var ir: ShaderGraphIR + var parameters: [ShaderGraphProgram.Parameter] + var textures: [ShaderGraphProgram.Texture] + var textureNames: [String: String] + } + + func analyzed() throws -> Analysis { let ir = try lowered() let numeric = ir.instructions.filter { $0.operation == .parameter && $0.type != .texture2D } let ids = Set(numeric.compactMap(\.reference)).sorted() @@ -79,6 +94,18 @@ extension ShaderGraph { } textureNames[node.nodeID] = "ada_graph_texture\(index)" } + return Analysis(ir: ir, parameters: parameters, textures: textures, textureNames: textureNames) + } +} + +extension ShaderGraph { + /// Generates GLSL without touching a GPU. User identifiers are never interpolated as shader code. + public func compileGLSL() throws -> ShaderGraphProgram { + let analysis = try analyzed() + let ir = analysis.ir + let parameters = analysis.parameters + let textures = analysis.textures + let textureNames = analysis.textureNames var lines = [ "layout(set = \(ShaderGraphProgram.resourceSet), binding = \(ShaderGraphProgram.uniformBinding)) uniform AdaGraphUniforms { vec4 ada_graph_values[\(parameters.count + 1)]; };" ] diff --git a/Sources/AdaRender/Shaders/Shader.swift b/Sources/AdaRender/Shaders/Shader.swift index a5bdab614..0b30d2259 100644 --- a/Sources/AdaRender/Shaders/Shader.swift +++ b/Sources/AdaRender/Shaders/Shader.swift @@ -141,12 +141,19 @@ extension Shader: UniqueHashable { } extension RenderBackendType { + /// Whether stages are consumed as WGSL: browser and native WebGPU, and the native SAGA runtime for WESL modules. + var consumesWGSL: Bool { + self == .webgpu || self == .saga + } + var deviceLang: ShaderLanguage { switch self { case .headless: return .glsl case .webgpu: return .wgsl + case .saga: + return .glsl case .metal: return .msl } diff --git a/Sources/AdaRender/Shaders/ShaderCache.swift b/Sources/AdaRender/Shaders/ShaderCache.swift index 42c4cf16a..2e185ac21 100644 --- a/Sources/AdaRender/Shaders/ShaderCache.swift +++ b/Sources/AdaRender/Shaders/ShaderCache.swift @@ -404,6 +404,9 @@ enum ShaderCache { // Native WebGPU consumes SPIR-V directly; keep its device shader cache // separate from the previous GLES prototype and other shader profiles. static let shadersDirectoryName = "Shaders-Android-WebGPU-v1" + #elseif WEBGPU_ENABLED && !WASM + // The source hash manifest and compiled artifacts must use the same cache root. + static let shadersDirectoryName = "Shaders-SAGA-v1" #else static let shadersDirectoryName = "Shaders" #endif diff --git a/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift b/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift index 79f740da8..c67eddb03 100644 --- a/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift +++ b/Sources/AdaRender/Shaders/ShaderCompiler/ShaderCompiler.swift @@ -181,7 +181,7 @@ public final class ShaderCompiler { return shader } - #if canImport(WebGPU) + #if canImport(WebGPU) || (WASM && WEBGPU_ENABLED) if unsafe RenderEngine.shared.type.deviceLang == .wgsl { guard let wgslSource = self.shaderSource.getWGSLSource(for: stage) else { let sourcePath = self.shaderSource.getSourceFileURL(for: stage)?.path ?? "" @@ -219,6 +219,17 @@ public final class ShaderCompiler { } #endif + #if SAGA_RENDER && !WASM + // SAGA consumes SPIR-V directly and derives its bindings from the compiled stage. + if unsafe RenderEngine.shared.type == .saga { + let shader = try Shader(spirv: binary, compiler: self) + let spirvCompiler = try SpirvCompiler(spriv: binary.data, stage: stage, deviceLang: .glsl) + shader.reflectionData = spirvCompiler.reflection() + try shader.compile() + return shader + } + #endif + #if WASM throw CompileError.failed("Synchronous device shader compilation is unavailable on WebAssembly") #else diff --git a/Sources/AdaRender/Shaders/ShaderCompiler/WGSLShaderCompiler.swift b/Sources/AdaRender/Shaders/ShaderCompiler/WGSLShaderCompiler.swift index e5b98b1cc..d7da28ab8 100644 --- a/Sources/AdaRender/Shaders/ShaderCompiler/WGSLShaderCompiler.swift +++ b/Sources/AdaRender/Shaders/ShaderCompiler/WGSLShaderCompiler.swift @@ -1,17 +1,6 @@ #if canImport(WebGPU) && !WASM && !os(Android) - import AdaUtils + import AdaWGSLCompiler import Foundation - import Logging - import Subprocess - import WebGPU - #if os(Windows) - import WinSDK - #endif - #if canImport(System) - @unsafe @preconcurrency import System - #else - @unsafe @preconcurrency import SystemPackage - #endif struct WGSLShaderCompiler: ShaderDeviceCompilerEngine { func compile( @@ -20,88 +9,31 @@ stage: ShaderStage, defines _: [ShaderDefine] ) async throws -> DeviceCompiledShader { - guard let toolExecutable = Bundle.adaModule.tintExecutable else { - throw ShaderCompilerError.tintNotFound + let target: AdaWGSLCompiler.Stage + switch stage { + case .vertex: target = .vertex + case .fragment: target = .fragment + case .compute: target = .compute + default: throw CompilationError.unsupportedStage(stage) } - - let tempFileURL = try getTempFileURL(from: spirvData) - - defer { try? FileManager.default.removeItem(at: tempFileURL) } - var options = PlatformOptions() - #if os(Windows) - // Studio and exported games have no parent console. Without this - // flag Windows creates a flashing console for every Tint invocation. - options.windowStyle = .hidden - options.preSpawnProcessConfigurator = { flags, _ in - flags |= DWORD(CREATE_NO_WINDOW) - } - #endif - let process = try await run( - .path(FilePath(toolExecutable.path)), - arguments: [ - tempFileURL.path, - "--format", - "wgsl", - "--allow-non-uniform-derivatives", - "true", - ], - platformOptions: options, - output: .string(limit: .max), - error: .string(limit: 1024) - ) - guard process.terminationStatus.isSuccess else { - throw ShaderCompilerError.failed(process.standardError ?? "Tint terminated with status \(process.terminationStatus)") - } - if let warnings = process.standardError, !warnings.isEmpty { - Logger(label: "org.adaengine.wgsl").warning("Tint: \(warnings)") - } - - guard let source = process.standardOutput else { - throw ShaderCompilerError.failed("No output") - } - let spirvCompiler = try SpirvCompiler(spriv: spirvData, stage: stage, deviceLang: .glsl) - let processedSource = renameEntryPoint(in: source, entryPoint: entryPoint) + let source = try AdaWGSLCompiler.compile(spirv: spirvData, entryPoint: entryPoint, stage: target) + let reflection = try SpirvCompiler(spriv: spirvData, stage: stage, deviceLang: .glsl).reflection() return DeviceCompiledShader( language: .wgsl, - entryPoints: [ - .init(name: entryPoint, stage: stage) - ], - reflection: spirvCompiler.reflection(), - source: processedSource + entryPoints: [.init(name: entryPoint, stage: stage)], + reflection: reflection, + source: source ) } - private func getTempFileURL(from sprivData: Data) throws -> URL { - let tempDirectory = FileManager.default.temporaryDirectory - let fileName = UUID().uuidString + ".spv" - let fileURL = tempDirectory.appendingPathComponent(fileName) - try sprivData.write(to: fileURL) - return fileURL - } - - // TODO: (Vlad) This is a temporary solution to rename the entry point. We need to find a better way to do this. - private func renameEntryPoint(in source: String, entryPoint: String) -> String { - return source.replacingOccurrences(of: "fn main(", with: "fn \(entryPoint)(") - } - - enum ShaderCompilerError: LocalizedError { - case tintNotFound - case failed(String) + private enum CompilationError: LocalizedError { + case unsupportedStage(ShaderStage) var errorDescription: String? { switch self { - case .tintNotFound: - return "Tint tool not found. Run python3 script/ensure_tint.py, or set TINT_EXECUTABLE to a verified SPIR-V reader / WGSL writer." - case let .failed(message): - return "Failed to compile shader: \(message)" + case .unsupportedStage(let stage): "Cannot compile the \(stage.rawValue) stage to WGSL." } } } } - - extension Bundle { - var tintExecutable: URL? { - TintToolchain.executable(bundle: self) - } - } #endif diff --git a/Sources/AdaRender/Shaders/ShaderModule+Required.swift b/Sources/AdaRender/Shaders/ShaderModule+Required.swift index a3c054e58..95b29d498 100644 --- a/Sources/AdaRender/Shaders/ShaderModule+Required.swift +++ b/Sources/AdaRender/Shaders/ShaderModule+Required.swift @@ -15,6 +15,12 @@ public extension ShaderModule { loadRequiredBundled(at: path, from: bundle, defines: []) } + /// Loads a migrated built-in shader using the format supported by the active renderer. + static func loadRequiredBuiltin(at path: String, from bundle: Bundle) -> AssetHandle { + let fileExtension = supportsBundledWESL ? "wesl" : "glsl" + return loadRequiredBundled(at: "\(path).\(fileExtension)", from: bundle) + } + /// Loads a required built-in variant while preserving its vertex and fragment output contract. static func loadRequiredBundled(at path: String, from bundle: Bundle, defines: [String]) -> AssetHandle { do { diff --git a/Sources/AdaRender/Shaders/ShaderModule.swift b/Sources/AdaRender/Shaders/ShaderModule.swift index ce0a8ac0b..80f2c41bd 100644 --- a/Sources/AdaRender/Shaders/ShaderModule.swift +++ b/Sources/AdaRender/Shaders/ShaderModule.swift @@ -67,7 +67,7 @@ public final class ShaderModule: Asset, @unchecked Sendable { /// - Throws: An error if the shader module cannot be initialized from the decoder. public init(from decoder: AssetDecoder) throws { let filePath = decoder.assetMeta.filePath - let module = try ShaderCompiler(from: filePath).compileShaderModule() + let module = try Self.create(from: filePath) self.shaders = module.shaders self.reflectionData = module.reflectionData } @@ -87,12 +87,34 @@ public final class ShaderModule: Asset, @unchecked Sendable { } extension ShaderModule { + /// Whether the active renderer can load bundled WESL shader modules. + public static var supportsBundledWESL: Bool { + #if (WASM && WEBGPU_ENABLED) || ((SAGA_RENDER || (canImport(WebGPU) && !os(Android))) && canImport(AdaWGSLCompiler)) + return unsafe RenderEngine.shared.type.consumesWGSL + #else + return false + #endif + } + /// Create a new shader module from a file url. /// /// - Parameter fileUrl: The file url to create the shader module from. /// - Returns: The shader module. public static func create(from fileUrl: URL) throws -> ShaderModule { - try ShaderCompiler(from: fileUrl).compileShaderModule() + #if canImport(AdaWGSLCompiler) && (SAGA_RENDER || (canImport(WebGPU) && !os(Android))) && !WASM + if fileUrl.pathExtension.lowercased() == "wesl" { + return try createWESL(from: fileUrl) + } + #endif + #if WASM && WEBGPU_ENABLED + if fileUrl.pathExtension.lowercased() == "wesl" { + guard let module = try createWGSLModuleIfAvailable(at: fileUrl, reflectActiveBindings: true) else { + throw ShaderModuleError.notFound(fileUrl.path + ".{vert,frag}.wgsl") + } + return module + } + #endif + return try ShaderCompiler(from: fileUrl).compileShaderModule() } /// Compile a bundled shader with every name in `defines` set to `1` in the vertex and fragment stages. @@ -104,8 +126,27 @@ extension ShaderModule { guard !defines.isEmpty else { return try loadBundled(at: path, from: bundle).asset } + if path.hasSuffix(".wesl") { + guard let resourceURL = bundle.resourceURL else { + throw ShaderModuleError.notFound(path) + } + let url = resourceURL.appendingPathComponent(path) + let names = Set(defines).sorted() + #if WASM && WEBGPU_ENABLED + let baseURL = url.appendingPathExtension(names.joined(separator: ".")) + guard let module = try createWGSLModuleIfAvailable(at: baseURL, reflectActiveBindings: true) else { + throw ShaderModuleError.notFound(baseURL.path) + } + return module + #elseif canImport(AdaWGSLCompiler) && (SAGA_RENDER || (canImport(WebGPU) && !os(Android))) + let features = Dictionary(uniqueKeysWithValues: names.map { ($0, true) }) + return try createWESL(from: url, features: features) + #else + throw ShaderModuleError.notFound(path) + #endif + } #if WASM - #if canImport(WebGPU) + #if WEBGPU_ENABLED if path == "Shaders/flat3d.glsl" { let variants = Set(defines).intersection(["ADA_TERRAIN", "ADA_WIREFRAME"]).sorted() if !variants.isEmpty, let resourceURL = bundle.resourceURL { @@ -153,7 +194,7 @@ extension ShaderModule { throw ShaderModuleError.notFound(path) } - #if WASM && canImport(WebGPU) + #if WASM && WEBGPU_ENABLED if path.hasSuffix(".glsl"), let wgslModule = try createWGSLModuleIfAvailable(at: url.deletingPathExtension()) { return AssetHandle(wgslModule) } @@ -174,9 +215,11 @@ private enum ShaderModuleError: LocalizedError { } } -#if WASM && canImport(WebGPU) +#if WASM && WEBGPU_ENABLED extension ShaderModule { - private static func createWGSLModuleIfAvailable(at baseURL: URL) throws -> ShaderModule? { + private static func createWGSLModuleIfAvailable( + at baseURL: URL, reflectActiveBindings: Bool = false + ) throws -> ShaderModule? { let vertexURL = baseURL.appendingPathExtension("vert.wgsl") let fragmentURL = baseURL.appendingPathExtension("frag.wgsl") @@ -191,18 +234,24 @@ private enum ShaderModuleError: LocalizedError { return nil } - let vertexReflection = WGSLReflection.parse(from: vertexSource, stage: .vertex) - let fragmentReflection = WGSLReflection.parse(from: fragmentSource, stage: .fragment) + let vertexEntry = entryPoint(in: vertexSource, attribute: "vertex") + let fragmentEntry = entryPoint(in: fragmentSource, attribute: "fragment") + let vertexReflection = WGSLReflection.parse( + from: vertexSource, stage: .vertex, entryPoint: reflectActiveBindings ? vertexEntry : nil + ) + let fragmentReflection = WGSLReflection.parse( + from: fragmentSource, stage: .fragment, entryPoint: reflectActiveBindings ? fragmentEntry : nil + ) let vertexShader = try makeWGSLShader( source: vertexSource, stage: .vertex, - entryPoint: entryPoint(in: vertexSource, attribute: "vertex"), + entryPoint: vertexEntry, reflectionData: vertexReflection ) let fragmentShader = try makeWGSLShader( source: fragmentSource, stage: .fragment, - entryPoint: entryPoint(in: fragmentSource, attribute: "fragment"), + entryPoint: fragmentEntry, reflectionData: fragmentReflection ) @@ -236,7 +285,7 @@ private enum ShaderModuleError: LocalizedError { } private static func entryPoint(in source: String, attribute: String) -> String { - let pattern = "@" + attribute + "\\s+fn\\s+([A-Za-z_][A-Za-z0-9_]*)" + let pattern = "@\\s*" + attribute + "\\s+fn\\s+([A-Za-z_][A-Za-z0-9_]*)" guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: source, range: NSRange(source.startIndex..., in: source)), diff --git a/Sources/AdaRender/Shaders/ShaderResource.swift b/Sources/AdaRender/Shaders/ShaderResource.swift index c7da08213..e1f5e12e2 100644 --- a/Sources/AdaRender/Shaders/ShaderResource.swift +++ b/Sources/AdaRender/Shaders/ShaderResource.swift @@ -77,6 +77,8 @@ public enum ShaderResource { public let arraySize: Int public internal(set) var shaderStage: ShaderStageFlags public let resourceAccess: ResourceAccess + // Optional so reflection cached before depth bindings were supported still decodes. + var isDepth: Bool? = nil } /// Describe reflected shader buffer information. That shader buffer contains members (properties) diff --git a/Sources/AdaRender/Shaders/WESLReflection.swift b/Sources/AdaRender/Shaders/WESLReflection.swift new file mode 100644 index 000000000..775403c66 --- /dev/null +++ b/Sources/AdaRender/Shaders/WESLReflection.swift @@ -0,0 +1,93 @@ +#if canImport(AdaWGSLCompiler) && !WASM && (SAGA_RENDER || !os(Android)) +import AdaWGSLCompiler +import Foundation + +enum WESLModuleError: LocalizedError { + case requiresWebGPU + case invalidEntryPoints + case uniformLayoutUnavailable(group: Int, binding: Int) + + var errorDescription: String? { + switch self { + case .requiresWebGPU: "WESL shader modules require a native renderer with WGSL support." + case .invalidEntryPoints: "A WESL shader module must have one entry point per active stage." + case let .uniformLayoutUnavailable(group, binding): + "Cannot adapt SAGA uniform layout at group \(group), binding \(binding); runtime reflection integration is incomplete." + } + } +} + +extension ShaderModule { + /// Reflection of one linked stage. Names and non-buffer resources come from the WGSL parser; buffer reachability and + /// byte sizes are authoritative SAGA metadata, never host strides. + static func weslStageReflection( + artifact: WESLHostCompiler.Artifact, + entry: WESLHostCompiler.EntryPoint, + stage: ShaderStage + ) throws -> ShaderReflectionData { + var stageReflection = WGSLReflection.parse(from: artifact.wgsl, stage: stage, entryPoint: entry.name) + let layouts = try WESLBackendCompiler.webGPUUniformBuffers(artifact, entryPoint: entry) + let parsedSets = stageReflection.descriptorSets + stageReflection.shaderBuffers.removeAll() + for index in stageReflection.descriptorSets.indices { + stageReflection.descriptorSets[index].uniformsBuffers.removeAll() + } + for layout in layouts { + let group = Int(layout.group) + let binding = Int(layout.binding) + guard parsedSets.indices.contains(group), + let parsed = parsedSets[group].uniformsBuffers[binding], + let size = Int(exactly: layout.minimumByteSize), size > 0 else { + throw WESLModuleError.uniformLayoutUnavailable(group: group, binding: binding) + } + let buffer = ShaderResource.ShaderBuffer( + name: parsed.name, + size: size, + shaderStage: ShaderStageFlags(shaderStage: stage), + binding: binding, + resourceAccess: .read, + members: parsed.members + ) + stageReflection.shaderBuffers[buffer.name] = buffer + stageReflection.descriptorSets[group].uniformsBuffers[binding] = buffer + } + return stageReflection + } +} + +extension ShaderGraphArchive.Fragment { + /// Links `template` with the graph's WGSL on the host and captures its fragment stage for the web runtime. + /// + /// - Parameters: + /// - template: The template's name, such as `flat3d`; it is part of the fragment's identity. + /// - templateSource: The WESL text whose `// ADA_GRAPH_SOURCE_MARKER` line receives the graph. + /// - packageRoot: Where the template's `package::` imports resolve. + /// - macros: Feature names the template is linked with, besides `ADA_SHADER_GRAPH`. + public static func bake( + template: String, + templateSource: String, + program: ShaderGraphProgram, + packageRoot: URL, + macros: [String] + ) throws -> ShaderGraphArchive.Fragment { + guard program.format == .wgsl else { throw ShaderGraphDiagnostic(code: .invalidReference, nodeID: "\(template): the graph is not WGSL") } + var features = Dictionary(uniqueKeysWithValues: macros.map { ($0, true) }) + features["ADA_SHADER_GRAPH"] = true + let engineRoot = Bundle.adaModule.resourceURL?.appendingPathComponent("Shaders/WESL", isDirectory: true) + let artifact = try WESLHostCompiler.compile( + source: program.inserting(in: templateSource), + packageRoot: packageRoot, + features: features, + dependencies: engineRoot.map { ["ada_engine": $0] } ?? [:] + ) + guard let entry = artifact.entryPoints.first(where: { $0.stage == "fragment" }) else { throw WESLModuleError.invalidEntryPoints } + return ShaderGraphArchive.Fragment( + source: identity(template: template, graphSource: program.source), + defines: Dictionary(uniqueKeysWithValues: macros.map { ($0, "1") }), + entryPoint: entry.name, + wgsl: artifact.wgsl, + reflection: try ShaderModule.weslStageReflection(artifact: artifact, entry: entry, stage: .fragment) + ) + } +} +#endif diff --git a/Sources/AdaRender/Shaders/WESLShaderModule.swift b/Sources/AdaRender/Shaders/WESLShaderModule.swift new file mode 100644 index 000000000..6e4de5eff --- /dev/null +++ b/Sources/AdaRender/Shaders/WESLShaderModule.swift @@ -0,0 +1,155 @@ +import Foundation + +#if canImport(AdaWGSLCompiler) && (SAGA_RENDER || (canImport(WebGPU) && !os(Android))) && !WASM +import AdaWGSLCompiler +import Foundation + +extension ShaderModule { + /// Link a local WESL module and create its WebGPU shader stages. + public static func createWESL( + from file: URL, + packageRoot: URL? = nil, + features: [String: Bool] = [:] + ) throws -> ShaderModule { + guard unsafe RenderEngine.shared.type.consumesWGSL else { + throw WESLModuleError.requiresWebGPU + } + let engineRoot = Bundle.adaModule.resourceURL?.appendingPathComponent("Shaders/WESL", isDirectory: true) + let artifact = try WESLHostCompiler.compile( + sourceFile: file, + packageRoot: packageRoot ?? file.deletingLastPathComponent(), + features: features, + dependencies: engineRoot.map { ["ada_engine": $0] } ?? [:] + ) + return try createModule(from: artifact) + } + + /// Link generated WESL source (for example a Shader Graph spliced into a template) and create its shader stages. + /// + /// - Parameter packageRoot: Where `package::` imports resolve; defaults to a private temporary directory. + public static func createWESL(source: String, packageRoot: URL? = nil, features: [String: Bool] = [:]) throws -> ShaderModule { + guard unsafe RenderEngine.shared.type.consumesWGSL else { + throw WESLModuleError.requiresWebGPU + } + let engineRoot = Bundle.adaModule.resourceURL?.appendingPathComponent("Shaders/WESL", isDirectory: true) + let artifact = try WESLHostCompiler.compile( + source: source, + packageRoot: packageRoot ?? FileManager.default.temporaryDirectory, + features: features, + dependencies: engineRoot.map { ["ada_engine": $0] } ?? [:] + ) + return try createModule(from: artifact) + } + + private static func createModule(from artifact: WESLHostCompiler.Artifact) throws -> ShaderModule { + var shaders: [ShaderStage: Shader] = [:] + var reflection = ShaderReflectionData() + for entry in artifact.entryPoints { + guard let stage = ShaderStage(rawValue: entry.stage), shaders[stage] == nil else { + throw WESLModuleError.invalidEntryPoints + } + let stageReflection = try weslStageReflection(artifact: artifact, entry: entry, stage: stage) + let shader = Shader(source: artifact.wgsl, entryPoint: entry.name, stage: stage, reflectionData: stageReflection) + try shader.compile() + shaders[stage] = shader + reflection.merge(stageReflection) + } + guard !shaders.isEmpty else { throw WESLModuleError.invalidEntryPoints } + return ShaderModule(shaders: shaders, reflectionData: reflection) + } +} + + +extension ShaderGraphMaterial { + /// Replaces only the fragment stage of `base`, like ``pipeline(base:device:variant:defines:fragmentTemplate:)``, from a + /// WESL template whose `// ADA_GRAPH_SOURCE_MARKER` line receives the graph's WGSL. + public func weslPipeline( + base: RenderPipelineDescriptor, + device: RenderDevice, + variant: String, + defines: [ShaderDefine] = [], + packageRoot: URL? = nil, + templateName: String, + fragmentTemplate: () throws -> String + ) throws -> RenderPipeline { + let key = ShaderGraphPipelineStorage.Key( + device: ObjectIdentifier(device), + vertex: ObjectIdentifier(base.vertex), + layout: base.vertexDescriptor, + variant: "wesl:" + variant, + defines: defines.sorted { $0.name < $1.name } + ) + let pipeline = try pipelineStorage.resolve(key, device: device, cacheFailure: true) { + let template = try fragmentTemplate() + guard template.contains("// ADA_GRAPH_SOURCE_MARKER") else { + throw ShaderGraphDiagnostic(code: .invalidReference) + } + let generated = try wgslProgram().inserting(in: template) + var sharedKey = key + sharedKey.source = generated + return try ShaderGraphPipelineStorage.shared.resolve(sharedKey, device: device, cacheFailure: false) { + var features = Dictionary(uniqueKeysWithValues: defines.map { ($0.name, true) }) + features["ADA_SHADER_GRAPH"] = true + let module = try ShaderModule.createWESL(source: generated, packageRoot: packageRoot, features: features) + guard let fragment = module.getShader(for: .fragment) else { throw ShaderGraphDiagnostic(code: .invalidReference) } + var descriptor = base + descriptor.fragment = fragment + descriptor.debugName += " Shader Graph" + return device.createRenderPipeline(from: descriptor) + } + } + recordPipelinePreparation() + return pipeline + } +} +#else +extension ShaderGraphMaterial { + #if WASM + /// A WebAssembly build links no shaders: it uses the fragment baked from the same template and graph. + public func weslPipeline( + base: RenderPipelineDescriptor, + device: RenderDevice, + variant: String, + defines: [ShaderDefine] = [], + packageRoot: URL? = nil, + templateName: String, + fragmentTemplate: () throws -> String + ) throws -> RenderPipeline { + let key = ShaderGraphPipelineStorage.Key( + device: ObjectIdentifier(device), + vertex: ObjectIdentifier(base.vertex), + layout: base.vertexDescriptor, + variant: "wesl:" + variant, + defines: defines.sorted { $0.name < $1.name } + ) + let pipeline = try pipelineStorage.resolve(key, device: device, cacheFailure: true) { + let identity = ShaderGraphArchive.Fragment.identity(template: templateName, graphSource: try wgslProgram().source) + guard let artifact = ShaderGraphRuntimeLibrary.fragment(source: identity, defines: defines) else { + throw ShaderGraphDiagnostic(code: .invalidReference, nodeID: "Missing baked Shader Graph variant: \(templateName) \(variant)") + } + let shader = Shader(source: artifact.wgsl, entryPoint: artifact.entryPoint, stage: .fragment, reflectionData: artifact.reflection) + try shader.compile() + var descriptor = base + descriptor.fragment = shader + descriptor.debugName += " Shader Graph" + return device.createRenderPipeline(from: descriptor) + } + recordPipelinePreparation() + return pipeline + } + #else + /// WESL templates need the native WGSL pipeline; this renderer cannot link them. + public func weslPipeline( + base: RenderPipelineDescriptor, + device: RenderDevice, + variant: String, + defines: [ShaderDefine] = [], + packageRoot: URL? = nil, + templateName: String, + fragmentTemplate: () throws -> String + ) throws -> RenderPipeline { + throw ShaderGraphDiagnostic(code: .invalidReference, nodeID: "WESL shader graph templates") + } + #endif +} +#endif diff --git a/Sources/AdaRender/Shaders/WGSLReflection.swift b/Sources/AdaRender/Shaders/WGSLReflection.swift index 76d25df8b..d78717ef3 100644 --- a/Sources/AdaRender/Shaders/WGSLReflection.swift +++ b/Sources/AdaRender/Shaders/WGSLReflection.swift @@ -6,7 +6,7 @@ enum WGSLReflection { var reflection = ShaderReflectionData() let usedNames = entryPoint.map { reachableNames(in: source, entryPoint: $0) } let stageFlag = ShaderStageFlags(shaderStage: stage) - let pattern = #"@group\(\s*(\d+)u?\s*\)\s*@binding\(\s*(\d+)u?\s*\)\s*var(?:<([^>]+)>)?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^;]+);"# + let pattern = #"@\s*group\s*\(\s*(\d+)u?\s*\)\s*@\s*binding\s*\(\s*(\d+)u?\s*\)\s*var(?:\s*<([^>]+)>)?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^;]+);"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return reflection } @@ -57,7 +57,8 @@ enum WGSLReflection { descriptorSet: group, arraySize: 1, shaderStage: stageFlag, - resourceAccess: .read + resourceAccess: .read, + isDepth: type.hasPrefix("texture_depth_") ? true : nil ) reflection.resources[name] = resource reflection.descriptorSets[group].sampledImages[binding] = resource @@ -79,9 +80,9 @@ enum WGSLReflection { let name = declaration.prefix { !$0.isWhitespace && $0 != "<" } return switch name { case "texture_1d": .texture1D - case "texture_2d_array": .texture2DArray + case "texture_2d_array", "texture_depth_2d_array": .texture2DArray case "texture_3d": .texture3D - case "texture_cube": .textureCube + case "texture_cube", "texture_depth_cube": .textureCube default: .texture2D } } diff --git a/Sources/AdaScene/3D/Model3DPlugin.swift b/Sources/AdaScene/3D/Model3DPlugin.swift index 4e2eb3a66..62cb76766 100644 --- a/Sources/AdaScene/3D/Model3DPlugin.swift +++ b/Sources/AdaScene/3D/Model3DPlugin.swift @@ -244,7 +244,7 @@ public final class Model3DDrawPass: MeshletGeometryDrawPass, @unchecked Sendable device: renderDevice, variant: "pbr:\(isMotion):\(isContact):\(configuration.blended):\(configuration.temporalDepth):\(configuration.features.rawValue)", features: configuration.features, - template: isContact ? "Shaders/directional_shadow_3d.glsl" : (isMotion ? "Shaders/motion_3d.glsl" : "Shaders/flat3d.glsl") + template: ShaderGraph3DPipeline.surfaceTemplatePath(isContact ? "directional_shadow_3d" : (isMotion ? "motion_3d" : "flat3d")) ) } catch { graph.reportCompilationError(error) diff --git a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift index c4ee9bc5f..0d5d9850b 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Materials.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Materials.swift @@ -1,6 +1,9 @@ @_spi(Internal) import AdaAssets @_spi(Internal) import AdaRender import AdaUtils +#if canImport(Dispatch) +import Dispatch +#endif import Foundation import Math import Synchronization @@ -70,9 +73,11 @@ extension ModelAsset3D { } // 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. + #if canImport(Dispatch) let jobs = remaining == nil ? parallelTextureJobs(result) : [] let prefetcher = jobs.isEmpty ? nil : TexturePrefetcher(jobs: jobs, lookahead: parallelTextureWindow) defer { prefetcher?.cancel() } + #endif func texture(_ index: Int?, sRGB: Bool) throws -> Texture2D? { guard let index else { return nil @@ -103,8 +108,12 @@ extension ModelAsset3D { guard baseBytes > 0, baseBytes <= available / factor else { throw ModelImportLimitError.decodedBudgetExceeded } remaining = available - baseBytes * factor } + #if canImport(Dispatch) // Always consume a prefetched result so the bounded prefetcher can advance. let prefetched = try prefetcher?.take(key) + #else + let prefetched: [Image]? = nil + #endif if let value = importedTextures.withLock({ $0[sharedKey]?.value }) { textures[key] = value return value @@ -212,6 +221,7 @@ extension ModelAsset3D { return try ImageMipmaps.make(from: image, colorSpace: sRGB ? .sRGB : .linear, includeMipmaps: sampler.mipFilter != .notMipmapped) } + #if canImport(Dispatch) private struct TextureJob: Sendable { let key: TextureKey let data: Data @@ -321,6 +331,8 @@ extension ModelAsset3D { } } + #endif + private static func samplerDescriptor(_ source: GLTFImportResult.Sampler) -> SamplerDescriptor { func address(_ value: Int) -> SamplerAddressMode { switch value { diff --git a/Sources/AdaSpine/Assets/spine.wesl b/Sources/AdaSpine/Assets/spine.wesl new file mode 100644 index 000000000..01adafae8 --- /dev/null +++ b/Sources/AdaSpine/Assets/spine.wesl @@ -0,0 +1,32 @@ +import ada_engine::view::projectPosition; + +struct SpineVertex { + @builtin(position) position: vec4f, + @location(0) lightColor: vec4f, + @location(1) uv: vec2f, + @location(2) darkColor: vec4f, +} + +@group(0) @binding(0) var atlas: texture_2d; +@group(0) @binding(1) var atlasSampler: sampler; + +@vertex +fn spine_vertex( + @location(0) position: vec4f, + @location(1) lightColor: vec4f, + @location(2) uv: vec2f, + @location(3) darkColor: vec4f, +) -> SpineVertex { + return SpineVertex(projectPosition(position), lightColor, uv, darkColor); +} + +@fragment +fn spine_fragment(input: SpineVertex) -> @location(0) vec4f { + let texel = textureSample(atlas, atlasSampler, input.uv); + let darkWeight = vec3f(mix(1.0, texel.a, input.darkColor.a)) - texel.rgb; + let color = vec4f(texel.rgb * input.lightColor.rgb + darkWeight * input.darkColor.rgb, texel.a * input.lightColor.a); + if color.a == 0.0 { + discard; + } + return color; +} diff --git a/Sources/AdaSpine/SpineRendering.swift b/Sources/AdaSpine/SpineRendering.swift index f2838cb56..59ee1115f 100644 --- a/Sources/AdaSpine/SpineRendering.swift +++ b/Sources/AdaSpine/SpineRendering.swift @@ -70,7 +70,7 @@ struct SpineRenderData: Resource { mutating func pipeline(blend: SpineDraw.BlendMode, premultiplied: Bool, device: RenderDevice) -> RenderPipeline { let key = PipelineKey(blend: blend.rawValue, premultiplied: premultiplied) if let pipeline = pipelines[key] { return pipeline } - let shader = shaderModule ?? ShaderModule.loadRequiredBundled(at: "Assets/spine.glsl", from: .module) + let shader = shaderModule ?? ShaderModule.loadRequiredBuiltin(at: "Assets/spine", from: .module) shaderModule = shader var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex)) descriptor.fragment = shader.asset.getShader(for: .fragment) diff --git a/Sources/AdaSprite/Assets/light2d_composite.wesl b/Sources/AdaSprite/Assets/light2d_composite.wesl new file mode 100644 index 000000000..cb4d5394c --- /dev/null +++ b/Sources/AdaSprite/Assets/light2d_composite.wesl @@ -0,0 +1,22 @@ +import package::light2d_fullscreen::{LightVertex, fullscreenLightVertex}; + +@vertex +fn light2d_composite_vert(@builtin(vertex_index) vertexIndex: u32) -> LightVertex { + return fullscreenLightVertex(vertexIndex); +} + +struct CompositeData { + modulate: vec4f, +} + +@group(0) @binding(0) var albedoImage: texture_2d; +@group(0) @binding(1) var lightAccum: texture_2d; +@group(0) @binding(2) var lightSampler: sampler; +@group(0) @binding(3) var composite: CompositeData; + +@fragment +fn light2d_composite_frag(input: LightVertex) -> @location(0) vec4f { + let albedo = textureSample(albedoImage, lightSampler, input.uv); + let addLight = textureSample(lightAccum, lightSampler, input.uv).rgb; + return vec4f(albedo.rgb * composite.modulate.rgb + albedo.rgb * addLight, albedo.a); +} diff --git a/Sources/AdaSprite/Assets/light2d_directional.wesl b/Sources/AdaSprite/Assets/light2d_directional.wesl new file mode 100644 index 000000000..21d415837 --- /dev/null +++ b/Sources/AdaSprite/Assets/light2d_directional.wesl @@ -0,0 +1,24 @@ +import package::light2d_fullscreen::{LightVertex, fullscreenLightVertex}; + +@vertex +fn light2d_directional_vert(@builtin(vertex_index) vertexIndex: u32) -> LightVertex { + return fullscreenLightVertex(vertexIndex); +} + +struct DirectionalLightData { + lightRGBEnergy: vec4f, + flags: vec4f, +} + +@group(0) @binding(0) var shadowMask: texture_2d; +@group(0) @binding(1) var shadowSampler: sampler; +@group(0) @binding(2) var light: DirectionalLightData; + +@fragment +fn light2d_directional_frag(input: LightVertex) -> @location(0) vec4f { + var rgb = light.lightRGBEnergy.rgb * light.lightRGBEnergy.a; + if light.flags.x > 0.5 { + rgb *= textureSample(shadowMask, shadowSampler, input.uv).r; + } + return vec4f(rgb, 1.0); +} diff --git a/Sources/AdaSprite/Assets/light2d_fullscreen.wgsl b/Sources/AdaSprite/Assets/light2d_fullscreen.wgsl new file mode 100644 index 000000000..185aaf719 --- /dev/null +++ b/Sources/AdaSprite/Assets/light2d_fullscreen.wgsl @@ -0,0 +1,9 @@ +struct LightVertex { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +fn fullscreenLightVertex(vertexIndex: u32) -> LightVertex { + let uv = vec2f(f32(vertexIndex >> 1u), f32(vertexIndex & 1u)) * 2.0; + return LightVertex(vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.0, 1.0), uv); +} diff --git a/Sources/AdaSprite/Assets/light2d_point.wesl b/Sources/AdaSprite/Assets/light2d_point.wesl new file mode 100644 index 000000000..7e3e1aee0 --- /dev/null +++ b/Sources/AdaSprite/Assets/light2d_point.wesl @@ -0,0 +1,37 @@ +import package::light2d_fullscreen::{LightVertex, fullscreenLightVertex}; + +@vertex +fn light2d_point_vert(@builtin(vertex_index) vertexIndex: u32) -> LightVertex { + return fullscreenLightVertex(vertexIndex); +} + +struct PointLightData { + invViewProjection: mat4x4f, + lightXYRadius: vec4f, + lightRGBEnergy: vec4f, + flags: vec4f, +} + +@group(0) @binding(0) var shadowMask: texture_2d; +@group(0) @binding(1) var shadowSampler: sampler; +@group(0) @binding(2) var cookie: texture_2d; +@group(0) @binding(3) var cookieSampler: sampler; +@group(0) @binding(4) var light: PointLightData; + +@fragment +fn light2d_point_frag(input: LightVertex) -> @location(0) vec4f { + let ndc = input.uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0); + let worldPosition = light.invViewProjection * vec4f(ndc, 0.0, 1.0); + let world = worldPosition.xy / max(worldPosition.w, 1e-5); + let radius = max(light.lightXYRadius.z, 1e-3); + let distanceToLight = distance(world, light.lightXYRadius.xy); + let attenuation = 1.0 - smoothstep(0.0, radius, distanceToLight); + var rgb = light.lightRGBEnergy.rgb * light.lightRGBEnergy.a * attenuation; + if light.flags.x > 0.5 { + rgb *= textureSample(shadowMask, shadowSampler, input.uv).r; + } + if light.flags.y > 0.5 { + rgb *= textureSample(cookie, cookieSampler, input.uv).rgb; + } + return vec4f(rgb, 1.0); +} diff --git a/Sources/AdaSprite/Assets/sprite.wesl b/Sources/AdaSprite/Assets/sprite.wesl new file mode 100644 index 000000000..757ee0dd8 --- /dev/null +++ b/Sources/AdaSprite/Assets/sprite.wesl @@ -0,0 +1,40 @@ +import ada_engine::view::projectPosition; +import package::sprite_surface::{SpriteVertex, shadeSprite}; + +// Shader Graph materials (ADA_SHADER_GRAPH) receive the generated graph source at this marker line. +// ADA_GRAPH_SOURCE_MARKER + +@if(ADA_SHADER_GRAPH) +fn shadeGraphSprite(input: SpriteVertex) -> vec4f { + let evaluated = ada_graph_evaluate(input.uv); + var color = evaluated.color * input.color; + color.a *= evaluated.alpha; + // Slot zero holds the render settings: y alpha cutoff, z mode (0 opaque, 1 mask, otherwise blended). + let settings = ada_graph_uniforms.values[0]; + if settings.z < 0.5 { + color.a = 1.0; + } else if settings.z < 1.5 { + if color.a < settings.y { + discard; + } + color.a = 1.0; + } + // Avoid depth writes for invisible texels. + if color.a == 0.0 { + discard; + } + return color; +} + +@vertex +fn sprite_vertex(@location(0) position: vec4f, @location(1) color: vec4f, @location(2) uv: vec2f) -> SpriteVertex { + return SpriteVertex(projectPosition(position), color, uv); +} + +@fragment +fn sprite_fragment(input: SpriteVertex) -> @location(0) vec4f { + @if(ADA_SHADER_GRAPH) { + return shadeGraphSprite(input); + } + return shadeSprite(input); +} diff --git a/Sources/AdaSprite/Assets/sprite_instanced.wesl b/Sources/AdaSprite/Assets/sprite_instanced.wesl new file mode 100644 index 000000000..aec93df66 --- /dev/null +++ b/Sources/AdaSprite/Assets/sprite_instanced.wesl @@ -0,0 +1,52 @@ +import ada_engine::view::projectPosition; +import package::sprite_surface::{SpriteVertex, shadeSprite}; + +// Shader Graph materials (ADA_SHADER_GRAPH) receive the generated graph source at this marker line. +// ADA_GRAPH_SOURCE_MARKER + +@if(ADA_SHADER_GRAPH) +fn shadeGraphSprite(input: SpriteVertex) -> vec4f { + let evaluated = ada_graph_evaluate(input.uv); + var color = evaluated.color * input.color; + color.a *= evaluated.alpha; + // Slot zero holds the render settings: y alpha cutoff, z mode (0 opaque, 1 mask, otherwise blended). + let settings = ada_graph_uniforms.values[0]; + if settings.z < 0.5 { + color.a = 1.0; + } else if settings.z < 1.5 { + if color.a < settings.y { + discard; + } + color.a = 1.0; + } + // Avoid depth writes for invisible texels. + if color.a == 0.0 { + discard; + } + return color; +} + +@vertex +fn sprite_instanced_vertex( + @location(0) corner: vec2f, + @location(1) origin: vec4f, + @location(2) axisX: vec4f, + @location(3) axisY: vec4f, + @location(4) color: vec4f, + @location(5) bottomUV: vec4f, + @location(6) topUV: vec4f, +) -> SpriteVertex { + let bottom = mix(bottomUV.xy, bottomUV.zw, corner.x); + let top = mix(topUV.xy, topUV.zw, corner.x); + let uv = mix(bottom, top, corner.y); + let position = origin + corner.x * axisX + corner.y * axisY; + return SpriteVertex(projectPosition(position), color, uv); +} + +@fragment +fn sprite_instanced_fragment(input: SpriteVertex) -> @location(0) vec4f { + @if(ADA_SHADER_GRAPH) { + return shadeGraphSprite(input); + } + return shadeSprite(input); +} diff --git a/Sources/AdaSprite/Assets/sprite_surface.wgsl b/Sources/AdaSprite/Assets/sprite_surface.wgsl new file mode 100644 index 000000000..34e00885e --- /dev/null +++ b/Sources/AdaSprite/Assets/sprite_surface.wgsl @@ -0,0 +1,16 @@ +struct SpriteVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +} + +@group(0) @binding(0) var spriteImage: texture_2d; +@group(0) @binding(1) var spriteSampler: sampler; + +fn shadeSprite(input: SpriteVertex) -> vec4f { + let color = textureSample(spriteImage, spriteSampler, input.uv) * input.color; + if color.a == 0.0 { + discard; + } + return color; +} diff --git a/Sources/AdaSprite/Lighting2D/Light2DPipelines.swift b/Sources/AdaSprite/Lighting2D/Light2DPipelines.swift index 7010807fe..18379e554 100644 --- a/Sources/AdaSprite/Lighting2D/Light2DPipelines.swift +++ b/Sources/AdaSprite/Lighting2D/Light2DPipelines.swift @@ -10,7 +10,7 @@ import AdaRender import AdaUtils import Math -/// GPU uniform for ``light2d_point.glsl`` (std140 layout). +/// GPU uniform for the point light pass. Matches the shader's 16-byte-aligned fields. public struct PointLightUBOGPU: Sendable { public var invViewProjection: Transform3D public var lightXYRadius: Vector4 @@ -30,7 +30,7 @@ public struct PointLightUBOGPU: Sendable { } } -/// GPU uniform for ``light2d_directional.glsl``. +/// GPU uniform for the directional light pass. public struct DirectionalLightUBOGPU: Sendable { public var lightRGBEnergy: Vector4 public var flags: Vector4 @@ -50,7 +50,7 @@ public struct Light2DRenderPipelines: Resource { public let sampler: Sampler public init(device: RenderDevice) { - let compositeShader = ShaderModule.loadRequiredBundled(at: "Assets/light2d_composite.glsl", from: .adaModule) + let compositeShader = Self.loadLightShader("composite") var compositeDesc = RenderPipelineDescriptor( vertex: compositeShader.asset.requiredShader(for: .vertex), fragment: compositeShader.asset.getShader(for: .fragment), @@ -63,7 +63,7 @@ public struct Light2DRenderPipelines: Resource { ] self.compositePipeline = device.createRenderPipeline(from: compositeDesc) - let pointShader = ShaderModule.loadRequiredBundled(at: "Assets/light2d_point.glsl", from: .adaModule) + let pointShader = Self.loadLightShader("point") var pointDesc = RenderPipelineDescriptor( vertex: pointShader.asset.requiredShader(for: .vertex), fragment: pointShader.asset.getShader(for: .fragment), @@ -83,7 +83,7 @@ public struct Light2DRenderPipelines: Resource { ] self.pointLightPipeline = device.createRenderPipeline(from: pointDesc) - let dirShader = ShaderModule.loadRequiredBundled(at: "Assets/light2d_directional.glsl", from: .adaModule) + let dirShader = Self.loadLightShader("directional") var dirDesc = RenderPipelineDescriptor( vertex: dirShader.asset.requiredShader(for: .vertex), fragment: dirShader.asset.getShader(for: .fragment), @@ -94,7 +94,7 @@ public struct Light2DRenderPipelines: Resource { dirDesc.colorAttachments = pointDesc.colorAttachments self.directionalLightPipeline = device.createRenderPipeline(from: dirDesc) - let quadShader = CorePipelineShaders.loadRequiredBundled(at: "Shaders/quad.glsl") + let quadShader = CorePipelineShaders.loadRequiredBuiltin("quad") var shadowDesc = RenderPipelineDescriptor( vertex: quadShader.asset.requiredShader(for: .vertex), fragment: quadShader.asset.getShader(for: .fragment), @@ -115,4 +115,8 @@ public struct Light2DRenderPipelines: Resource { self.sampler = device.createSampler(from: SamplerDescriptor()) } + + private static func loadLightShader(_ pass: String) -> AssetHandle { + ShaderModule.loadRequiredBuiltin(at: "Assets/light2d_\(pass)", from: .adaModule) + } } diff --git a/Sources/AdaSprite/SpriteGraphPipeline.swift b/Sources/AdaSprite/SpriteGraphPipeline.swift index 41d273853..d1a78071c 100644 --- a/Sources/AdaSprite/SpriteGraphPipeline.swift +++ b/Sources/AdaSprite/SpriteGraphPipeline.swift @@ -1,4 +1,5 @@ import AdaRender +import AdaUtils import Foundation public enum SpriteGraphPipeline { @@ -6,9 +7,42 @@ public enum SpriteGraphPipeline { guard material.program.target == .sprite else { throw ShaderGraphDiagnostic(code: .typeMismatch, nodeID: "Sprite") } - return try material.pipeline(base: base.descriptor, device: device, variant: instanced ? "sprite-instanced" : "sprite-expanded") { - try fragmentTemplate(instanced: instanced) + let variant = instanced ? "sprite-instanced" : "sprite-expanded" + let name = instanced ? "sprite_instanced" : "sprite" + #if WASM + // A WebAssembly build links no shaders: it uses the fragment AdaShaderGraphBaker linked from the WESL template. + return try material.weslPipeline(base: base.descriptor, device: device, variant: variant, templateName: name) { "" } + #else + // The native renderer links the WESL templates at runtime. + if ShaderModule.supportsBundledWESL { + return try material.weslPipeline( + base: base.descriptor, device: device, variant: variant, + packageRoot: Bundle.adaModule.resourceURL?.appendingPathComponent("Assets", isDirectory: true), + templateName: name + ) { + try weslTemplate(instanced: instanced) + } + } + return try material.pipeline(base: base.descriptor, device: device, variant: variant) { + try fragmentTemplate(instanced: instanced) + } + #endif + } + + /// Where the sprite templates' `package::` imports resolve. + public static var weslPackageRoot: URL { + Bundle.adaModule.resourceURL?.appendingPathComponent("Assets", isDirectory: true) ?? URL(fileURLWithPath: "/") + } + + /// The WESL sprite template; its `// ADA_GRAPH_SOURCE_MARKER` line receives the graph's WGSL. + public static func weslTemplate(instanced: Bool) throws -> String { + let path = instanced ? "Assets/sprite_instanced.wesl" : "Assets/sprite.wesl" + guard let url = Bundle.adaModule.resourceURL?.appendingPathComponent(path), + let data = FileSystem.current.readFile(at: url), + let source = String(bytes: data, encoding: .utf8) else { + throw ShaderGraphDiagnostic(code: .invalidReference, nodeID: path) } + return source } /// The production sprite fragment used by offline graph export. diff --git a/Sources/AdaSprite/SpriteInstancedRenderPipeline.swift b/Sources/AdaSprite/SpriteInstancedRenderPipeline.swift index 7b27eecec..288a41b10 100644 --- a/Sources/AdaSprite/SpriteInstancedRenderPipeline.swift +++ b/Sources/AdaSprite/SpriteInstancedRenderPipeline.swift @@ -4,7 +4,7 @@ import AdaRender import Math struct SpriteInstancedRenderPipeline: RenderPipelineConfigurator { - let shader = ShaderModule.loadRequiredBundled(at: "Assets/sprite_instanced.glsl", from: .adaModule) + let shader = ShaderModule.loadRequiredBuiltin(at: "Assets/sprite_instanced", from: .adaModule) func configurate(with _: RenderPipelineEmptyConfiguration) -> RenderPipelineDescriptor { var descriptor = RenderPipelineDescriptor(vertex: shader.asset.requiredShader(for: .vertex)) diff --git a/Sources/AdaSprite/SpriteRenderPipeline.swift b/Sources/AdaSprite/SpriteRenderPipeline.swift index 9b64b38e9..74feee81e 100644 --- a/Sources/AdaSprite/SpriteRenderPipeline.swift +++ b/Sources/AdaSprite/SpriteRenderPipeline.swift @@ -13,7 +13,7 @@ public struct SpriteRenderPipeline: RenderPipelineConfigurator { public let spriteShader: AssetHandle public init() { - self.spriteShader = ShaderModule.loadRequiredBundled(at: "Assets/sprite.glsl", from: .adaModule) + self.spriteShader = ShaderModule.loadRequiredBuiltin(at: "Assets/sprite", from: .adaModule) } } diff --git a/Sources/AdaText/Assets/text.wesl b/Sources/AdaText/Assets/text.wesl new file mode 100644 index 000000000..4cac5a4fe --- /dev/null +++ b/Sources/AdaText/Assets/text.wesl @@ -0,0 +1,50 @@ +import ada_engine::view::projectPosition; + +struct TextVertex { + @builtin(position) position: vec4f, + @location(0) foreground: vec4f, + @location(1) outline: vec4f, + @location(2) outlineWidth: f32, + @location(3) uv: vec2f, +} + +@group(0) @binding(0) var fontAtlas: texture_2d; +@group(0) @binding(1) var fontSampler: sampler; + +@vertex +fn text_vertex( + @location(0) position: vec4f, + @location(1) foreground: vec4f, + @location(2) outline: vec4f, + @location(3) outlineWidth: f32, + @location(4) uv: vec2f, +) -> TextVertex { + return TextVertex(projectPosition(position), foreground, outline, outlineWidth, uv); +} + +fn screenPixelRange(uv: vec2f) -> f32 { + // Keep this in sync with FontAtlasGenerator.atlasPixelRange. + let unitRange = vec2f(4.0) / vec2f(textureDimensions(fontAtlas, 0)); + let screenSize = vec2f(1.0) / fwidth(uv); + return max(0.5 * dot(unitRange, screenSize), 1.0); +} + +fn medianDistance(msd: vec3f) -> f32 { + return max(min(msd.r, msd.g), min(max(msd.r, msd.g), msd.b)); +} + +@fragment +fn text_fragment(input: TextVertex) -> @location(0) vec4f { + let msd = textureSample(fontAtlas, fontSampler, input.uv).rgb; + let distance = screenPixelRange(input.uv) * (medianDistance(msd) - 0.5); + let fillAlpha = clamp(distance + 0.5, 0.0, 1.0) * input.foreground.a; + let outlineAlpha = clamp(distance + input.outlineWidth + 0.5, 0.0, 1.0) * input.outline.a; + let visibleOutline = outlineAlpha * (1.0 - fillAlpha); + let alpha = fillAlpha + visibleOutline; + let premultiplied = input.foreground.rgb * fillAlpha + input.outline.rgb * visibleOutline; + // The pipeline blends straight-alpha RGB. + if alpha > 0.0 { + return vec4f(premultiplied / alpha, alpha); + } + return vec4f(vec3f(0.0), alpha); +} diff --git a/Sources/AdaText/Text/Font/FontAtlasGenerator.swift b/Sources/AdaText/Text/Font/FontAtlasGenerator.swift index ca8f6abba..bff7472e5 100644 --- a/Sources/AdaText/Text/Font/FontAtlasGenerator.swift +++ b/Sources/AdaText/Text/Font/FontAtlasGenerator.swift @@ -191,10 +191,13 @@ final class FontAtlasGenerator: Sendable { // MARK: - Private private func makeTextureAtlas(from data: Data, width: Int, height: Int) -> Texture2D { + let supportsFloat32Filtering = unsafe RenderEngine.shared.renderDevice.supportsFloat32TextureFiltering + // Keep cached atlas data at its original precision and convert only the GPU upload. + let pixels = supportsFloat32Filtering ? data : Self.halfPrecisionPixels(from: data) let image = Image( width: width, height: height, - data: data + data: pixels ) var textSamplerDesc = SamplerDescriptor() @@ -205,7 +208,7 @@ final class FontAtlasGenerator: Sendable { let descriptor = TextureDescriptor( width: width, height: height, - pixelFormat: .rgba_32f, + pixelFormat: supportsFloat32Filtering ? .rgba_32f : .rgba_16f, textureUsage: [.read], textureType: .texture2D, mipmapLevel: 0, @@ -216,6 +219,17 @@ final class FontAtlasGenerator: Sendable { return Texture2D(descriptor: descriptor) } + private static func halfPrecisionPixels(from data: Data) -> Data { + let stride = MemoryLayout.stride + let components = unsafe data.withUnsafeBytes { bytes in + (0..<(bytes.count / stride)).map { index in + let component = unsafe bytes.loadUnaligned(fromByteOffset: index * stride, as: Float.self) + return Float16(component).bitPattern + } + } + return unsafe components.withUnsafeBufferPointer { unsafe Data(buffer: $0) } + } + private func makeAtlasDescriptor(from fontDescriptor: FontDescriptor) -> font_atlas_descriptor { var atlasFontDescriptor = font_atlas_descriptor() atlasFontDescriptor.angleThreshold = 3.0 diff --git a/Sources/AdaText/TextPlugin.swift b/Sources/AdaText/TextPlugin.swift index 5f7617ac8..78e097e11 100644 --- a/Sources/AdaText/TextPlugin.swift +++ b/Sources/AdaText/TextPlugin.swift @@ -39,8 +39,7 @@ public struct TextPipeline: RenderPipelineConfigurator { private let shader: AssetHandle public init() { - self.shader = (try? ShaderModule.loadBundled(at: "Assets/text.glsl", from: .adaModule)) - .unwrap(message: "Bundled text shader is missing.") + self.shader = ShaderModule.loadRequiredBuiltin(at: "Assets/text", from: .adaModule) } public func configurate( @@ -74,7 +73,7 @@ public struct TextPipeline: RenderPipelineConfigurator { } /// Vertex data for rendering text glyphs. -/// Matches the layout expected by text.glsl shader. +/// Matches the text shader's vertex layout. public struct GlyphVertexData: Sendable { /// Position in world space (vec4). public let position: Vector4 diff --git a/Sources/AdaTilemap/Assets/tilemap_chunk.wesl b/Sources/AdaTilemap/Assets/tilemap_chunk.wesl new file mode 100644 index 000000000..20be865eb --- /dev/null +++ b/Sources/AdaTilemap/Assets/tilemap_chunk.wesl @@ -0,0 +1,30 @@ +import ada_engine::view::view; + +struct TileVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +} + +struct TileMapModel { + transform: mat4x4f, +} + +@group(0) @binding(0) var atlas: texture_2d; +@group(0) @binding(1) var atlasSampler: sampler; +@group(0) @binding(3) var model: TileMapModel; + +@vertex +fn tilemap_chunk_vertex(@location(0) position: vec4f, @location(1) color: vec4f, @location(2) uv: vec2f) -> TileVertex { + return TileVertex(view.viewProjection * model.transform * position, color, uv); +} + +@fragment +fn tilemap_chunk_fragment(input: TileVertex) -> @location(0) vec4f { + let color = textureSample(atlas, atlasSampler, input.uv) * input.color; + if color.a == 0.0 { + discard; + } + return color; +} + diff --git a/Sources/AdaTilemap/TileMapChunkPipeline.swift b/Sources/AdaTilemap/TileMapChunkPipeline.swift index ab8f48824..309b687d9 100644 --- a/Sources/AdaTilemap/TileMapChunkPipeline.swift +++ b/Sources/AdaTilemap/TileMapChunkPipeline.swift @@ -7,7 +7,7 @@ struct TileMapChunkPipeline: RenderPipelineConfigurator, WorldInitable { let shader: AssetHandle init(from _: World) { - shader = ShaderModule.loadRequiredBundled(at: "Assets/tilemap_chunk.glsl", from: .adaModule) + shader = ShaderModule.loadRequiredBuiltin(at: "Assets/tilemap_chunk", from: .adaModule) } func configurate(with _: RenderPipelineEmptyConfiguration) -> RenderPipelineDescriptor { diff --git a/Sources/AdaUI/Assets/Shaders/ui_shadow.wesl b/Sources/AdaUI/Assets/Shaders/ui_shadow.wesl new file mode 100644 index 000000000..eb7e51bdc --- /dev/null +++ b/Sources/AdaUI/Assets/Shaders/ui_shadow.wesl @@ -0,0 +1,42 @@ +import ada_engine::view::projectPosition; + +struct UIShadowMaterial { + u_Color: vec4f, + u_Geometry: vec4f, + u_Style: vec4f, +} + +struct UIShadowVertex { + @builtin(position) position: vec4f, + @location(0) color: vec4f, + @location(1) uv: vec2f, +} + +@group(0) @binding(0) var material: UIShadowMaterial; + +@vertex +fn ui_shadow_vertex(@location(0) position: vec4f, @location(1) color: vec4f, @location(2) uv: vec2f) -> UIShadowVertex { + return UIShadowVertex(projectPosition(position), color, uv); +} + +fn roundedBoxDistance(point: vec2f, halfSize: vec2f, radius: f32) -> f32 { + let offset = abs(point) - halfSize + vec2f(radius); + return length(max(offset, vec2f(0.0))) + min(max(offset.x, offset.y), 0.0) - radius; +} + +@fragment +fn ui_shadow_fragment(input: UIShadowVertex) -> @location(0) vec4f { + let viewSize = max(material.u_Geometry.xy, vec2f(1.0)); + let inset = material.u_Geometry.z; + let cornerRadius = clamp(material.u_Geometry.w, 0.0, min(viewSize.x, viewSize.y) * 0.5); + let effectSize = viewSize + vec2f(inset * 2.0); + // Quad UV Y increases upward; modifier offsets use screen Y downward. + let point = vec2f(input.uv.x, 1.0 - input.uv.y) * effectSize - vec2f(inset) - viewSize * 0.5; + let sourceDistance = roundedBoxDistance(point, viewSize * 0.5, cornerRadius); + let shadowDistance = roundedBoxDistance(point - material.u_Style.xy, viewSize * 0.5, cornerRadius); + let softness = max(material.u_Style.z * 0.45, 0.5); + let halo = 1.0 / (1.0 + exp(shadowDistance / softness)); + let outsideSource = smoothstep(-0.5, 0.75, sourceDistance); + let alpha = clamp(material.u_Color.a * material.u_Style.w * halo * outsideSource, 0.0, 1.0); + return vec4f(material.u_Color.rgb * alpha, alpha); +} diff --git a/Sources/AdaUI/CoreUI/UIClipboard.swift b/Sources/AdaUI/CoreUI/UIClipboard.swift index f19734de2..c0a349ae8 100644 --- a/Sources/AdaUI/CoreUI/UIClipboard.swift +++ b/Sources/AdaUI/CoreUI/UIClipboard.swift @@ -9,7 +9,7 @@ import Foundation #if canImport(AppKit) import AppKit -#elseif canImport(UIKit) +#elseif canImport(UIKit) && !os(tvOS) import UIKit #elseif os(Windows) import WinSDK @@ -49,7 +49,7 @@ public enum UIClipboard { return fallbackString } return nil - #elseif canImport(UIKit) + #elseif canImport(UIKit) && !os(tvOS) let pasteboard = UIPasteboard.general if let text = pasteboard.string { return text @@ -80,7 +80,7 @@ public enum UIClipboard { pasteboard.setString(value, forType: .string) fallbackString = value fallbackPasteboardChangeCount = pasteboard.changeCount - #elseif canImport(UIKit) + #elseif canImport(UIKit) && !os(tvOS) UIPasteboard.general.string = value #elseif os(Windows) let characters = Array(value.utf16) + [0] diff --git a/Sources/AdaUI/DSL/Modifiers/ShadowModifier.swift b/Sources/AdaUI/DSL/Modifiers/ShadowModifier.swift index 4477b4377..3e1554076 100644 --- a/Sources/AdaUI/DSL/Modifiers/ShadowModifier.swift +++ b/Sources/AdaUI/DSL/Modifiers/ShadowModifier.swift @@ -88,6 +88,12 @@ struct UIShadowMaterial: UIShaderMaterial { ) } + /// The WESL module links both stages; the GLSL stage sources below remain only for backends without WESL support. + static func shaderModule() throws -> ShaderModule? { + guard ShaderModule.supportsBundledWESL else { return nil } + return try ShaderModule.createBundled(at: "Shaders/ui_shadow.wesl", from: .adaModule, defines: []) + } + static func fragmentShader() throws -> AssetHandle { let relativePath = "Shaders/ui_shadow.glsl" #if WASM diff --git a/Sources/AdaUI/DSL/Nodes/TextFieldViewNode+TextNavigation.swift b/Sources/AdaUI/DSL/Nodes/TextFieldViewNode+TextNavigation.swift index 3e9b0bd54..bacd1d94b 100644 --- a/Sources/AdaUI/DSL/Nodes/TextFieldViewNode+TextNavigation.swift +++ b/Sources/AdaUI/DSL/Nodes/TextFieldViewNode+TextNavigation.swift @@ -25,13 +25,13 @@ extension TextFieldViewNode { self.selectWord(at: caretOffset) self.clearTapCandidate() - #if canImport(UIKit) + #if canImport(UIKit) && !os(tvOS) self.showEditMenu(at: position) #endif } else { self.storeTapCandidate(at: position, time: time) - #if canImport(UIKit) + #if canImport(UIKit) && !os(tvOS) if self.hasSelection { self.showEditMenu(at: position) } else { diff --git a/Sources/AdaUI/DSL/Nodes/TextFieldViewNode.swift b/Sources/AdaUI/DSL/Nodes/TextFieldViewNode.swift index c703e038b..ece6c7d90 100644 --- a/Sources/AdaUI/DSL/Nodes/TextFieldViewNode.swift +++ b/Sources/AdaUI/DSL/Nodes/TextFieldViewNode.swift @@ -177,7 +177,7 @@ final class TextFieldViewNode: ViewNode { self.mousePressStartPoint = nil self.touchPressStartPoint = nil self.clearTapCandidate() - #if canImport(UIKit) + #if canImport(UIKit) && !os(tvOS) self.hideEditMenu() #endif } @@ -290,7 +290,7 @@ final class TextFieldViewNode: ViewNode { if self.isDoubleTouchTap { self.selectWord(at: caretOffset) self.clearTapCandidate() - #if canImport(UIKit) + #if canImport(UIKit) && !os(tvOS) self.showEditMenu(at: localPoint) #endif } else { @@ -1219,7 +1219,7 @@ extension TextFieldViewNode { } } -#if canImport(UIKit) +#if canImport(UIKit) && !os(tvOS) import UIKit extension TextFieldViewNode { @@ -1240,7 +1240,7 @@ extension TextFieldViewNode { func hideEditMenu() { UIMenuController.shared.hideMenu() #if !os(visionOS) - UIMenuController.shared.setMenuVisible(false, animated: true) + UIMenuController.shared.setMenuVisible(false, animated: true) #endif } } diff --git a/Sources/AdaWGSLCompiler/AdaWGSLCompiler.swift b/Sources/AdaWGSLCompiler/AdaWGSLCompiler.swift new file mode 100644 index 000000000..ee3d91ee7 --- /dev/null +++ b/Sources/AdaWGSLCompiler/AdaWGSLCompiler.swift @@ -0,0 +1,56 @@ +import Foundation +import SAGACompiler +import SAGAFrontends +import SAGAShaderIR + +public enum AdaWGSLCompiler { + public enum Stage: Sendable { + case vertex, fragment, compute + } + + public static func compile(spirv: Data, entryPoint: String, stage: Stage) throws -> String { + let wordSize = MemoryLayout.size + guard !spirv.isEmpty, spirv.count.isMultiple(of: wordSize) else { + throw CompilationError.invalidSPIRV + } + let words = unsafe spirv.withUnsafeBytes { bytes in + stride(from: 0, to: bytes.count, by: wordSize).map { offset in + UInt32(littleEndian: unsafe bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self)) + } + } + // AdaEngine's glslang input is already in the engine's WebGPU clip space. + let compiler = IndependentShaderCompiler( + spirvOptions: .init(adjustCoordinateSpace: false, allowNonUniformDerivatives: true) + ) + let input = ShaderInput.spirv(words) + let artifact: ShaderArtifact + let generatedEntryPoint: String + switch stage { + case .vertex, .fragment: + let result = try compiler.compileWebGPURenderStage(input, entryPoint: "main") + artifact = result.artifact + generatedEntryPoint = result.entryPoint + case .compute: + let result = try compiler.compileWebGPUCompute(input, entryPoint: "main") + artifact = result.artifact + generatedEntryPoint = result.entryPoint + } + + guard case .text(.wgsl, let source) = artifact else { + throw CompilationError.invalidOutput + } + return source.replacingOccurrences(of: "fn \(generatedEntryPoint)(", with: "fn \(entryPoint)(") + } + + private enum CompilationError: LocalizedError { + case invalidSPIRV + case invalidOutput + + var errorDescription: String? { + switch self { + case .invalidSPIRV: "Invalid SPIR-V word data." + case .invalidOutput: "SAGACompiler did not emit WGSL source." + } + } + } +} diff --git a/Sources/AdaWGSLCompiler/WESLBackendCompiler.swift b/Sources/AdaWGSLCompiler/WESLBackendCompiler.swift new file mode 100644 index 000000000..6117d10d2 --- /dev/null +++ b/Sources/AdaWGSLCompiler/WESLBackendCompiler.swift @@ -0,0 +1,143 @@ +#if !os(WASI) +import Foundation +import SAGACompiler +import SAGAShaderIR + +/// Emits native backend stages from a linked WESL module, retaining SAGA's authoritative reflection. +public enum WESLBackendCompiler { + /// Native code formats supported by the shader migration. WGSL uses the host artifact directly. + public enum Target: String, Sendable { + case msl + case spirv + } + + /// Reflection belongs to the emitted entry point, including Metal's remapped binding indices. + public enum Reflection: Sendable { + case render(RenderStageArtifact) + case compute(ComputeShaderArtifact) + } + + public struct Stage: Sendable { + public let sourceEntryPoint: String + public let entryPoint: String + public let stage: String + public let reflection: Reflection + public let data: Data + + public var fileExtension: String { + switch stage { + case "vertex": "vert" + case "fragment": "frag" + default: "comp" + } + } + } + + /// Compiles each named stage independently; never assumes a multi-entry module is a compute shader. + /// The returned code alone is not a runtime pipeline: consumers must apply its matching reflection. + public static func compile(_ module: WESLHostCompiler.Artifact, to target: Target) throws -> [Stage] { + guard !module.entryPoints.isEmpty else { + throw ShaderFailure.unsupported("Native WESL output requires at least one entry point") + } + let compiler = IndependentShaderCompiler() + let output: ShaderOutputFormat = target == .msl ? .msl : .spirv + return try module.entryPoints.map { entry in + let artifact: ShaderArtifact + let reflection: Reflection + let emittedEntryPoint: String + switch entry.stage { + case "vertex", "fragment": + let compiled = try compiler.compileRenderStage(.wgsl(module.wgsl), entryPoint: entry.name, to: output) + artifact = compiled.artifact + reflection = .render(compiled) + emittedEntryPoint = compiled.entryPoint + case "compute": + let compiled = try compiler.compileCompute(.wgsl(module.wgsl), entryPoint: entry.name, to: output) + artifact = compiled.artifact + reflection = .compute(compiled) + emittedEntryPoint = compiled.entryPoint + default: + throw ShaderFailure.unsupported("WESL backend stage '\(entry.stage)'") + } + return Stage( + sourceEntryPoint: entry.name, + entryPoint: emittedEntryPoint, + stage: entry.stage, + reflection: reflection, + data: encode(artifact) + ) + } + } + + /// Minimum sizes of the uniform buffers statically reachable by one WebGPU entry point. + /// This intentionally does not infer material property names or member layouts from source text. + public struct UniformBufferLayout: Sendable { + public let group: UInt32 + public let binding: UInt32 + public let minimumByteSize: UInt64 + } + + /// Reads SAGA-validated IR layouts without generating replacement WGSL or allocating Metal slots. + /// Only fixed uniform types are supported; unresolved layouts fail closed. + public static func webGPUUniformBuffers( + _ module: WESLHostCompiler.Artifact, + entryPoint: WESLHostCompiler.EntryPoint + ) throws -> [UniformBufferLayout] { + let ir = try IndependentShaderCompiler().parseAndValidate(.wgsl(module.wgsl)).module + guard let entry = ir.entryPoints.first(where: { $0.name == entryPoint.name && $0.stage.rawValue == entryPoint.stage }) else { + throw ShaderFailure.unsupported("Unknown WESL uniform-reflection entry point '\(entryPoint.name)'") + } + var pending = [entry.function] + var visited: Set = [] + var globals: Set = [] + // Match SAGA's static resource model: references in every reachable function's IR arena. + while let handle = pending.popLast() { + try Task.checkCancellation() + guard visited.insert(handle.index).inserted else { continue } + let function = try ir.functions.value(for: handle) + for statement in function.statements.values { + if case .call(let callee, _, _) = statement.kind { pending.append(callee) } + } + for expression in function.expressions.values { + if case .globalVariable(let global) = expression.kind { globals.insert(global.index) } + } + } + return try ir.globalVariables.values.enumerated().compactMap { index, global in + guard globals.contains(index), global.space == .uniform, let binding = global.binding else { + return nil + } + let type = try ir.types.value(for: global.type).kind + let size: UInt64 + switch type { + case .structure(_, let span): size = UInt64(span) + case .scalar(let scalar): size = UInt64(scalar.width) + case let .vector(count, scalar): size = UInt64(count.rawValue) * UInt64(scalar.width) + case let .matrix(columns, rows, scalar): + let columnStride = UInt64(rows == .two ? 2 : 4) * UInt64(scalar.width) + size = UInt64(columns.rawValue) * columnStride + case let .array(_, .constant(count), stride): size = UInt64(count) * UInt64(stride) + default: + throw ShaderFailure.unsupported("WESL uniform type requires a fixed runtime layout adapter") + } + guard size > 0, size <= 65_536 else { + throw ShaderFailure.unsupported("WESL uniform byte size is outside the supported buffer range") + } + return UniformBufferLayout(group: binding.group, binding: binding.binding, minimumByteSize: size) + } + } + + private static func encode(_ artifact: ShaderArtifact) -> Data { + switch artifact { + case .text(_, let source): + return Data(source.utf8) + case .spirv(let words): + var data = Data(capacity: words.count * MemoryLayout.size) + for word in words { + var littleEndian = word.littleEndian + withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) } + } + return data + } + } +} +#endif diff --git a/Sources/AdaWGSLCompiler/WESLConditions.swift b/Sources/AdaWGSLCompiler/WESLConditions.swift new file mode 100644 index 000000000..45fcd124b --- /dev/null +++ b/Sources/AdaWGSLCompiler/WESLConditions.swift @@ -0,0 +1,197 @@ +#if !os(WASI) +import Foundation + +struct WESLConditions { + let tokens: [String] + let features: [String: Bool] + let file: URL + + func expand() throws -> [String] { + try expand(tokens.indices) + } + + private func expand(_ range: Range, depth: Int = 0) throws -> [String] { + guard depth < 128 else { throw failure("WESL conditional nesting limit exceeded") } + var result: [String] = [] + var cursor = range.lowerBound + var previousMatch: Bool? + while cursor < range.upperBound { + guard tokens[cursor] == "@", + let branch = try conditionalAttributes(at: cursor, limit: range.upperBound) else { + result.append(tokens[cursor]) + cursor += 1 + previousMatch = nil + continue + } + let selected: Bool + switch branch.kind { + case "if": + selected = branch.enabled + previousMatch = selected + case "elif": + guard let previousMatch else { throw failure("Unexpected @elif without @if") } + selected = !previousMatch && branch.enabled + default: + guard let previousMatch else { throw failure("Unexpected @else without @if") } + selected = !previousMatch + } + let end = try nodeEnd(from: branch.body, limit: range.upperBound) + if selected { + result += branch.attributes + result += try expand(branch.body.. Branch? { + var cursor = start + var ordinary: [String] = [] + var condition: (kind: String, enabled: Bool)? + while cursor + 1 < limit, tokens[cursor] == "@" { + let attributeStart = cursor + let name = tokens[cursor + 1] + cursor += 2 + var argument: Range? + if cursor < limit, tokens[cursor] == "(" { + let end = try closingParen(from: cursor, limit: limit) + argument = (cursor + 1).. Int { + guard start < limit else { throw failure("Missing conditional WESL declaration") } + var stack: [String] = [] + var readingType = false + let isImport = tokens[start] == "import" + let isAlias = tokens[start] == "alias" + var index = start + while index < limit { + let token = tokens[index] + switch token { + case ":": + if stack.isEmpty { readingType = true } + case "=": + if stack.isEmpty { readingType = isAlias } + case "<": + if readingType || (index > start && tokens[index - 1] == "var") { stack.append(">") } + case ">": + if stack.last == ">" { stack.removeLast() } + case ">>": + if stack.last == ">" { stack.removeLast() } + if stack.last == ">" { stack.removeLast() } + case "(": stack.append(")") + case "[": stack.append("]") + case "{": stack.append("}") + case ")", "]", "}": + if stack.isEmpty { return index } + guard stack.popLast() == token else { throw failure("Unbalanced delimiters in WESL declaration") } + if token == "}", stack.isEmpty, !isImport, + index + 1 == limit || tokens[index + 1] != "else" { + return index + 1 + } + case ";", ",": + if stack.isEmpty { return index + 1 } + default: break + } + index += 1 + } + guard stack.isEmpty else { throw failure("Unterminated conditional WESL declaration") } + return limit + } + + private func closingParen(from start: Int, limit: Int) throws -> Int { + var depth = 0 + for index in start.. WESLLinker.Failure { + .init("\(message) in \(file.path)") + } + + private struct Branch { + let kind: String + let enabled: Bool + let attributes: [String] + let body: Int + } + + private struct Evaluator { + var tokens: ArraySlice + let features: [String: Bool] + + mutating func evaluate() throws -> Bool { + let value = try disjunction(depth: 0) + guard tokens.isEmpty else { throw InvalidExpression() } + return value + } + + private mutating func disjunction(depth: Int) throws -> Bool { + var value = try conjunction(depth: depth) + while consume("||") { + let right = try conjunction(depth: depth) + value = value || right + } + return value + } + + private mutating func conjunction(depth: Int) throws -> Bool { + var value = try unary(depth: depth) + while consume("&&") { + let right = try unary(depth: depth) + value = value && right + } + return value + } + + private mutating func unary(depth: Int) throws -> Bool { + guard depth < 128 else { throw InvalidExpression() } + if consume("!") { return try !unary(depth: depth + 1) } + if consume("(") { + let value = try disjunction(depth: depth + 1) + guard consume(")") else { throw InvalidExpression() } + return value + } + guard let token = tokens.popFirst(), let first = token.first, + first == "_" || first.isLetter else { throw InvalidExpression() } + if token == "true" { return true } + if token == "false" { return false } + return features[token, default: false] + } + + private mutating func consume(_ value: String) -> Bool { + guard tokens.first == value else { return false } + tokens.removeFirst() + return true + } + + private struct InvalidExpression: Error {} + } +} +#endif diff --git a/Sources/AdaWGSLCompiler/WESLHostCompiler.swift b/Sources/AdaWGSLCompiler/WESLHostCompiler.swift new file mode 100644 index 000000000..bffed34b2 --- /dev/null +++ b/Sources/AdaWGSLCompiler/WESLHostCompiler.swift @@ -0,0 +1,79 @@ +#if !os(WASI) +import Foundation +import SAGACompiler +import SAGAShaderIR +import SAGAValidation + +/// Links local WESL source files in-process and validates the generated WGSL. +public enum WESLHostCompiler { + public struct Artifact: Codable, Sendable { + public let wgsl: String + public let sources: [String] + public let entryPoints: [EntryPoint] + } + + public struct EntryPoint: Codable, Sendable { + public let name: String + public let stage: String + } + + public static func compile( + packageRoot: URL, + module: String, + features: [String: Bool] = [:], + dependencies: [String: URL] = [:] + ) throws -> Artifact { + guard dependencies["package"] == nil else { + throw WESLLinker.Failure("The name package is reserved for the current WESL package") + } + var roots = dependencies.mapValues { $0.resolvingSymlinksInPath().standardizedFileURL } + roots["package"] = packageRoot.resolvingSymlinksInPath().standardizedFileURL + var linker = WESLLinker(roots: roots, features: features) + let linked = try linker.link(module) + let validated = try IndependentShaderCompiler().parseAndValidate(.wgsl(linked.wgsl)) + let entries = validated.module.entryPoints.map { EntryPoint(name: $0.name, stage: $0.stage.rawValue) } + return Artifact(wgsl: linked.wgsl, sources: linked.sources, entryPoints: entries) + } + + /// Links WESL source held in memory as a module of the package at `packageRoot`, so it can import that package's + /// modules and the dependencies. Shader Graph templates are generated this way. + public static func compile( + source: String, + packageRoot: URL, + features: [String: Bool] = [:], + dependencies: [String: URL] = [:] + ) throws -> Artifact { + guard dependencies["package"] == nil else { + throw WESLLinker.Failure("The name package is reserved for the current WESL package") + } + var roots = dependencies.mapValues { $0.resolvingSymlinksInPath().standardizedFileURL } + roots["package"] = packageRoot.resolvingSymlinksInPath().standardizedFileURL + let module = "package::ada_generated_entry" + var linker = WESLLinker(roots: roots, features: features) + linker.sourceOverrides[module] = source + let linked = try linker.link(module) + let validated = try IndependentShaderCompiler().parseAndValidate(.wgsl(linked.wgsl)) + let entries = validated.module.entryPoints.map { EntryPoint(name: $0.name, stage: $0.stage.rawValue) } + return Artifact(wgsl: linked.wgsl, sources: linked.sources, entryPoints: entries) + } + + /// Links a file relative to an explicit shader package root. + public static func compile( + sourceFile: URL, + packageRoot: URL, + features: [String: Bool] = [:], + dependencies: [String: URL] = [:] + ) throws -> Artifact { + let file = sourceFile.resolvingSymlinksInPath().standardizedFileURL + let root = packageRoot.resolvingSymlinksInPath().standardizedFileURL + let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/" + guard file.path.hasPrefix(prefix) else { + throw WESLLinker.Failure("The WESL input must be under its package root.") + } + let relative = file.deletingPathExtension().path.dropFirst(prefix.count) + let components = relative.split(separator: "/").map(String.init) + let module = components == ["package"] ? "package" : (["package"] + components).joined(separator: "::") + return try compile(packageRoot: root, module: module, features: features, dependencies: dependencies) + } +} +#endif diff --git a/Sources/AdaWGSLCompiler/WESLLinker.swift b/Sources/AdaWGSLCompiler/WESLLinker.swift new file mode 100644 index 000000000..f45a6aa65 --- /dev/null +++ b/Sources/AdaWGSLCompiler/WESLLinker.swift @@ -0,0 +1,368 @@ +#if !os(WASI) +import Foundation + +struct WESLLinker { + let roots: [String: URL] + let features: [String: Bool] + /// Module source supplied in memory, by module path. It replaces (or stands in for) the module's file, so generated + /// source can import siblings from the package without being written under the package root. + var sourceOverrides: [String: String] = [:] + private var modules: [String: Module] = [:] + private var loading: Set = [] + private var ordered: [String] = [] + + init(roots: [String: URL], features: [String: Bool]) { + self.roots = roots + self.features = features + } + + mutating func link(_ entry: String) throws -> (wgsl: String, sources: [String]) { + guard roots.keys.allSatisfy(isIdentifier), roots["super"] == nil else { + throw Failure("WESL package names must be identifiers other than super") + } + guard features.keys.allSatisfy(isIdentifier) else { + throw Failure("WESL feature names must be identifiers") + } + try load(entry) + var directives: Set = [] + var declarations: [String] = [] + for path in ordered { + guard let module = modules[path] else { continue } + directives.formUnion(module.directives) + let names = try names(for: module, rootModule: entry) + try declarations.append(rewrite(module, names: names, rootModule: entry)) + } + return ( + wgsl: (directives.sorted() + declarations).joined(separator: "\n"), + sources: ordered.compactMap { modules[$0]?.file.path } + ) + } + + private mutating func load(_ path: String) throws { + if modules[path] != nil { return } + guard loading.count < 128 else { throw Failure("WESL import nesting limit exceeded at \(path)") } + guard loading.insert(path).inserted else { throw Failure("Cyclic WESL import: \(path)") } + defer { loading.remove(path) } + let file: URL + let text: String + if let override = sourceOverrides[path] { + file = (try? moduleFile(path)) ?? (roots["package"] ?? URL(fileURLWithPath: "/")).appendingPathComponent("generated.wesl") + text = override + } else { + file = try moduleFile(path) + text = try String(contentsOf: file, encoding: .utf8) + } + let raw = try tokenize(text, file: file) + let conditions = WESLConditions(tokens: raw, features: features, file: file) + let tokens = try conditions.expand() + var cursor = 0 + var imports: [Import] = [] + while tokens.indices.contains(cursor), tokens[cursor] == "import" { + cursor += 1 + try parseImports(tokens, cursor: &cursor, prefix: [], into: &imports, file: file) + guard tokens.indices.contains(cursor), tokens[cursor] == ";" else { + throw Failure("Expected ';' after import in \(file.path)") + } + cursor += 1 + } + var moduleAliases: [String: String] = [:] + var items: [String: Reference] = [:] + for item in imports { + let qualified = try absolutePath(item.parts, in: path, file: file) + let alias = item.alias ?? item.parts.last ?? "" + guard moduleAliases[alias] == nil, items[alias] == nil else { + throw Failure("Duplicate import '\(alias)' in \(file.path)") + } + if (try? moduleFile(qualified)) != nil { + try load(qualified) + moduleAliases[alias] = qualified + } else { + let parts = qualified.components(separatedBy: "::") + guard parts.count >= 2, let name = parts.last else { throw Failure("Invalid import in \(file.path)") } + let owner = parts.dropLast().joined(separator: "::") + try load(owner) + guard modules[owner]?.globals.values.contains(name) == true else { + throw Failure("Imported declaration '\(qualified)' not found in \(file.path)") + } + items[alias] = Reference(module: owner, name: name) + } + } + var directives: [String] = [] + while tokens.indices.contains(cursor), ["enable", "requires", "diagnostic"].contains(tokens[cursor]) { + guard let end = tokens[cursor...].firstIndex(of: ";") else { + throw Failure("Incomplete directive in \(file.path)") + } + directives.append(tokens[cursor...end].joined(separator: " ")) + cursor = end + 1 + } + let body = Array(tokens[cursor...]) + let globals = try globalNames(body, file: file) + var position = 0 + while position + 1 < body.count { + guard body[position + 1] == "::" else { position += 1; continue } + let reference = qualifiedReference(body, at: position) + let anchor = reference.parts[0] + let qualified = if let alias = moduleAliases[anchor] { + ([alias] + reference.parts.dropFirst()).joined(separator: "::") + } else { + try absolutePath(reference.parts, in: path, file: file) + } + let owner = qualified.components(separatedBy: "::").dropLast().joined(separator: "::") + if owner != path { try load(owner) } + position = reference.end + } + modules[path] = Module(path: path, file: file, tokens: body, globals: globals, items: items, + aliases: moduleAliases, directives: directives) + ordered.append(path) + } + + private func moduleFile(_ path: String) throws -> URL { + let parts = path.components(separatedBy: "::") + guard let name = parts.first, let root = roots[name], parts.dropFirst().allSatisfy(isIdentifier) else { + throw Failure("Invalid WESL module path: \(path)") + } + let components = parts.count == 1 ? ["package"] : Array(parts.dropFirst()) + let base = components.reduce(root) { $0.appendingPathComponent($1) } + for ext in ["wesl", "wgsl"] { + let file = base.appendingPathExtension(ext).resolvingSymlinksInPath().standardizedFileURL + let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/" + guard file.path.hasPrefix(prefix) else { throw Failure("WESL module escapes the package root: \(path)") } + if (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { return file } + } + throw Failure("WESL module '\(path)' not found under \(root.path)") + } + + private func absolutePath(_ parts: [String], in module: String, file: URL) throws -> String { + let current = module.components(separatedBy: "::") + if parts.first == "package", let name = current.first { + return ([name] + parts.dropFirst()).joined(separator: "::") + } + if let name = parts.first, roots[name] != nil { + return parts.joined(separator: "::") + } + let parents = parts.prefix { $0 == "super" }.count + guard parents > 0, parents < current.count else { + throw Failure("Expected package::, a registered dependency, or a valid super:: path in \(file.path)") + } + return (Array(current.dropLast(parents)) + parts.dropFirst(parents)).joined(separator: "::") + } + + private func parseImports( + _ tokens: [String], cursor: inout Int, prefix: [String], into imports: inout [Import], file: URL, + depth: Int = 0 + ) throws { + guard depth < 128 else { throw Failure("WESL import is too deeply nested in \(file.path)") } + guard tokens.indices.contains(cursor) else { throw Failure("Incomplete import in \(file.path)") } + if tokens[cursor] == "{" { + cursor += 1 + while tokens.indices.contains(cursor), tokens[cursor] != "}" { + try parseImports(tokens, cursor: &cursor, prefix: prefix, into: &imports, file: file, depth: depth + 1) + if tokens.indices.contains(cursor), tokens[cursor] == "," { cursor += 1 } + else { break } + } + guard tokens.indices.contains(cursor), tokens[cursor] == "}" else { + throw Failure("Expected '}' in grouped import in \(file.path)") + } + cursor += 1 + return + } + let segment = tokens[cursor] + guard isIdentifier(segment) else { throw Failure("Expected an explicit import name in \(file.path)") } + cursor += 1 + if tokens.indices.contains(cursor), tokens[cursor] == "::" { + cursor += 1 + try parseImports(tokens, cursor: &cursor, prefix: prefix + [segment], into: &imports, file: file, depth: depth + 1) + return + } + var alias: String? + if tokens.indices.contains(cursor), tokens[cursor] == "as" { + cursor += 1 + guard tokens.indices.contains(cursor), isIdentifier(tokens[cursor]) else { + throw Failure("Expected import alias in \(file.path)") + } + alias = tokens[cursor] + cursor += 1 + } + imports.append(Import(parts: prefix + [segment], alias: alias)) + } + + private func names(for module: Module, rootModule: String) throws -> [String: String] { + var result = module.items.mapValues { emittedName($0.name, in: $0.module, rootModule: rootModule) } + for name in module.globals.values { + guard result[name] == nil, module.aliases[name] == nil else { + throw Failure("Declaration '\(name)' conflicts with an import in \(module.file.path)") + } + result[name] = emittedName(name, in: module.path, rootModule: rootModule) + } + return result + } + + private func emittedName(_ name: String, in module: String, rootModule: String) -> String { + guard module != rootModule else { return name } + let path = module.components(separatedBy: "::").map { "\($0.utf8.count)_\($0)" }.joined(separator: "_") + return "ada_wesl_\(path)_\(name)" + } + + private func rewrite(_ module: Module, names: [String: String], rootModule: String) throws -> String { + var result: [String] = [] + let tokens = module.tokens + let rootGlobals = Set(modules[rootModule]?.globals.values.map { $0 } ?? []) + var index = 0 + while index < tokens.count { + let token = tokens[index] + let preceding = index > 0 ? tokens[index - 1] : "" + let next = index + 1 < tokens.count ? tokens[index + 1] : "" + if next == "::", preceding != "." { + let (parts, end) = qualifiedReference(tokens, at: index) + let qualified = if let alias = module.aliases[token] { + ([alias] + parts.dropFirst()).joined(separator: "::") + } else { + try absolutePath(parts, in: module.path, file: module.file) + } + let path = qualified.components(separatedBy: "::") + let owner = path.dropLast().joined(separator: "::") + guard let name = path.last, modules[owner]?.globals.values.contains(name) == true else { + throw Failure("Unresolved qualified name '\(qualified)' in \(module.file.path)") + } + result.append(emittedName(name, in: owner, rootModule: rootModule)) + index = end + } else { + if module.globals[index] != nil { + result.append(names[token] ?? token) + } else if preceding == "." || preceding == "@" { + result.append(token) + } else { + if names[token] != nil, next == ":" || ["let", "const", "var"].contains(preceding) { + throw Failure("Local declaration '\(token)' shadows a WESL binding in \(module.file.path)") + } + if module.path != rootModule, names[token] == nil, rootGlobals.contains(token) { + throw Failure("Name '\(token)' is not imported in \(module.file.path)") + } + result.append(names[token] ?? token) + } + index += 1 + } + } + return result.joined(separator: " ") + } + + private func qualifiedReference(_ tokens: [String], at start: Int) -> (parts: [String], end: Int) { + var parts = [tokens[start]] + var end = start + 1 + while end + 1 < tokens.count, tokens[end] == "::", isIdentifier(tokens[end + 1]) { + parts.append(tokens[end + 1]) + end += 2 + } + return (parts, end) + } + + private func globalNames(_ tokens: [String], file: URL) throws -> [Int: String] { + var names: Set = [] + var declarations: [Int: String] = [:] + var depth = 0 + for (index, token) in tokens.enumerated() { + if token == "{" { depth += 1 } + if token == "}" { depth -= 1 } + if depth == 0, ["fn", "struct", "alias", "const", "override", "var"].contains(token) { + var nameIndex = index + 1 + if token == "var", tokens.indices.contains(nameIndex), tokens[nameIndex] == "<" { + guard let end = tokens[nameIndex...].firstIndex(of: ">") else { continue } + nameIndex = end + 1 + } + guard tokens.indices.contains(nameIndex), isIdentifier(tokens[nameIndex]) else { continue } + let name = tokens[nameIndex] + guard !name.hasPrefix("ada_wesl_"), names.insert(name).inserted else { + throw Failure("Duplicate or reserved global name '\(name)' in \(file.path)") + } + declarations[nameIndex] = name + } + } + return declarations + } + + private struct Module { + let path: String + let file: URL + let tokens: [String] + let globals: [Int: String] + let items: [String: Reference] + let aliases: [String: String] + let directives: [String] + } + + private struct Import { + let parts: [String] + let alias: String? + } + + private struct Reference { + let module: String + let name: String + } + + struct Failure: LocalizedError, CustomStringConvertible { + let description: String + var errorDescription: String? { description } + + init(_ description: String) { + self.description = description + } + } +} + +private func isIdentifier(_ text: String) -> Bool { + guard let first = text.first, first == "_" || first.isLetter else { return false } + return text.dropFirst().allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber } +} + +private func tokenize(_ source: String, file: URL) throws -> [String] { + let chars = Array(source) + var index = 0 + var tokens: [String] = [] + while index < chars.count { + let current = chars[index] + let next = index + 1 < chars.count ? chars[index + 1] : " " + let start = index + if current.isWhitespace { + index += 1 + continue + } + if current == "/", next == "/" { + while index < chars.count, !chars[index].isNewline { index += 1 } + continue + } + if current == "/", next == "*" { + index += 2 + var depth = 1 + while index + 1 < chars.count, depth > 0 { + if chars[index] == "/", chars[index + 1] == "*" { depth += 1; index += 2 } + else if chars[index] == "*", chars[index + 1] == "/" { depth -= 1; index += 2 } + else { index += 1 } + } + guard depth == 0 else { throw WESLLinker.Failure("Unterminated comment in \(file.path)") } + continue + } + index += 1 + if current.isLetter || current == "_" { + while index < chars.count, chars[index].isLetter || chars[index].isNumber || chars[index] == "_" { index += 1 } + } else if current.isNumber || (current == "." && next.isNumber) { + while index < chars.count { + let char = chars[index] + if char.isLetter || char.isNumber || char == "." || + ((char == "+" || char == "-") && "eEpP".contains(chars[index - 1])) { + index += 1 + } else { break } + } + } else { + let pair = String([current, next]) + if ["::", "->", "&&", "||", "==", "!=", "<=", ">=", "<<", ">>", "++", "--", + "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^="].contains(pair) { + index += 1 + if (pair == "<<" || pair == ">>"), index < chars.count, chars[index] == "=" { index += 1 } + } + } + tokens.append(String(chars[start.. Tolerance { - backend == .webgpu ? .webgpu : .metal + backend == .metal ? .metal : .webgpu } /// Tolerance for comparing two renders of the active backend with each other or with its references. diff --git a/Tests/AdaEngineTests/GoldenFrames/saga-browser/smoke-160x96.png b/Tests/AdaEngineTests/GoldenFrames/saga-browser/smoke-160x96.png new file mode 100644 index 000000000..c7be3dd73 Binary files /dev/null and b/Tests/AdaEngineTests/GoldenFrames/saga-browser/smoke-160x96.png differ diff --git a/Tests/AdaEngineTests/MovieMakerGPUTests.swift b/Tests/AdaEngineTests/MovieMakerGPUTests.swift index 3ac1d18c0..c552f0235 100644 --- a/Tests/AdaEngineTests/MovieMakerGPUTests.swift +++ b/Tests/AdaEngineTests/MovieMakerGPUTests.swift @@ -10,7 +10,13 @@ extension Render3DGPUTests { func recordsCameraPixelsAndCameraCut() async throws { let previousBackend = unsafe RenderEngine.configurations.preferredBackend defer { unsafe RenderEngine.configurations.preferredBackend = previousBackend } - unsafe RenderEngine.configurations.preferredBackend = GoldenFrame.activeBackend == .webgpu ? .webgpu : .metal + let backend = try #require(GoldenFrame.activeBackend) + let requestedBackend: RenderBackendType = switch backend { + case .metal: .metal + case .webgpu: .webgpu + case .saga: .saga + } + unsafe RenderEngine.configurations.preferredBackend = requestedBackend let root = FileManager.default.temporaryDirectory.appendingPathComponent("AdaMovieGPU-\(UUID())") defer { try? FileManager.default.removeItem(at: root) } var first: Entity? @@ -32,6 +38,7 @@ extension Render3DGPUTests { second?.components[Camera.self]?.isActive = true } }) + try #require(unsafe RenderEngine.shared.type == requestedBackend) let red = try Image(contentsOf: root.appendingPathComponent("gpu00000000.png")) let blue = try Image(contentsOf: root.appendingPathComponent("gpu00000001.png")) #expect(red.width == 37 && red.height == 19) diff --git a/Tests/AdaEngineTests/PixelPerfectMetalTests.swift b/Tests/AdaEngineTests/PixelPerfectMetalTests.swift index b555f2518..7d31ed644 100644 --- a/Tests/AdaEngineTests/PixelPerfectMetalTests.swift +++ b/Tests/AdaEngineTests/PixelPerfectMetalTests.swift @@ -16,6 +16,10 @@ import Math import Metal import Testing +private let pixelPerfectSAGASmokeEnabled = ProcessInfo.processInfo.environment["ADAENGINE_PIXEL_PERFECT_SAGA_SMOKE"] == "1" +private let pixelPerfectGPUSmokeEnabled = + pixelPerfectSAGASmokeEnabled || ProcessInfo.processInfo.environment["ADAENGINE_PIXEL_PERFECT_METAL_SMOKE"] == "1" + /// Renders a pixel-perfect camera through the whole engine (camera extraction, render target preparation, the /// 2D render graph and the upscale node) on Metal and reads the output back. /// Run alone in a fresh process, like the other Metal suites: @@ -23,12 +27,10 @@ import Testing @Suite("Pixel perfect GPU rendering", .serialized) @MainActor struct PixelPerfectMetalTests { - nonisolated private static let enabled = ProcessInfo.processInfo.environment["ADAENGINE_PIXEL_PERFECT_METAL_SMOKE"] == "1" - private let scene = Color(red: 0.1, green: 0.2, blue: 0.6, alpha: 1) private let letterbox = Color(red: 0.9, green: 0.1, blue: 0.9, alpha: 1) - @Test(.enabled(if: PixelPerfectMetalTests.enabled)) + @Test(.enabled(if: pixelPerfectGPUSmokeEnabled)) func integerScaleCentresACrispPicture() async throws { // 8x6 art pixels into a 40x28 output: 4x scale, a 32x24 picture at (4, 2). let frame = try await render(output: SizeInt(width: 40, height: 28), resolution: SizeInt(width: 8, height: 6)) @@ -63,7 +65,7 @@ struct PixelPerfectMetalTests { #expect(strays == 0, "\(strays) picture pixels were blended") } - @Test(.enabled(if: PixelPerfectMetalTests.enabled)) + @Test(.enabled(if: pixelPerfectGPUSmokeEnabled)) func scaleFollowsTheOutputSize() async throws { // The same 8x6 picture in a 100x50 output: 8x scale, a 64x48 picture at (18, 1). let frame = try await render(output: SizeInt(width: 100, height: 50), resolution: SizeInt(width: 8, height: 6)) @@ -76,7 +78,7 @@ struct PixelPerfectMetalTests { expectColor(image.getPixel(x: 81, y: 48), .green, "bottom-right art pixel") } - @Test(.enabled(if: PixelPerfectMetalTests.enabled)) + @Test(.enabled(if: pixelPerfectGPUSmokeEnabled)) func fitModeFillsTheLimitingAxis() async throws { let frame = try await render(output: SizeInt(width: 40, height: 28), resolution: SizeInt(width: 8, height: 6), mode: .fit) let image = frame.image @@ -86,7 +88,7 @@ struct PixelPerfectMetalTests { expectColor(image.getPixel(x: 20, y: 27), scene, "no border below", allowing: [.red, .green]) } - @Test(.enabled(if: PixelPerfectMetalTests.enabled)) + @Test(.enabled(if: pixelPerfectGPUSmokeEnabled)) func aCameraWithoutThePresetStillFillsTheOutput() async throws { let frame = try await render(output: SizeInt(width: 40, height: 28), resolution: nil) let image = frame.image @@ -97,7 +99,7 @@ struct PixelPerfectMetalTests { } } - @Test(.enabled(if: PixelPerfectMetalTests.enabled)) + @Test(.enabled(if: pixelPerfectGPUSmokeEnabled)) func uiIsDrawnAtOutputResolutionOnTopOfTheScaledScene() async throws { // 8x6 art pixels into 40x28: 4x scale, picture at (4, 2). Two UI blocks are placed in output pixels: // one inside the picture at an offset that is not a multiple of the scale, one in the border. @@ -203,11 +205,18 @@ struct PixelPerfectMetalTests { } private func render(output: SizeInt, resolution: SizeInt?, mode: PixelPerfectSettings.ScaleMode = .integer, uiBlocks: [Rect] = []) async throws -> Frame { - unsafe RenderEngine.configurations.preferredBackend = .metal + let backend: RenderBackendType = pixelPerfectSAGASmokeEnabled ? .saga : .metal + unsafe RenderEngine.configurations.preferredBackend = backend try RenderEngine.setupRenderEngine() - try #require(unsafe RenderEngine.shared.type == .metal, "Metal must be active; a headless fallback proves nothing.") + try #require(unsafe RenderEngine.shared.type == backend, "Requested GPU backend must be active.") let device = unsafe RenderEngine.shared.renderDevice - let queue = try #require(MetalInterop.commandQueue(for: device)) + let queue: (any MTLCommandQueue)? + if pixelPerfectSAGASmokeEnabled { + queue = nil + } else { + let nativeQueue = try #require(MetalInterop.commandQueue(for: device)) + queue = nativeQueue + } let app = AppWorlds(main: World()) app.insertResource(OffscreenRenderWorld()) @@ -250,10 +259,12 @@ struct PixelPerfectMetalTests { for _ in 0..<4 { try await app.update() } - let fence = try #require(queue.makeCommandBuffer()) - await withCheckedContinuation { continuation in - fence.addCompletedHandler { _ in continuation.resume() } - fence.commit() + if let queue { + let fence = try #require(queue.makeCommandBuffer()) + await withCheckedContinuation { continuation in + fence.addCompletedHandler { _ in continuation.resume() } + fence.commit() + } } return Frame(image: try #require(try await device.readImage(from: target))) } diff --git a/Tests/AdaEngineTests/Render3DDebugViewTests.swift b/Tests/AdaEngineTests/Render3DDebugViewTests.swift index 2d272ec5d..f7ead4b83 100644 --- a/Tests/AdaEngineTests/Render3DDebugViewTests.swift +++ b/Tests/AdaEngineTests/Render3DDebugViewTests.swift @@ -261,7 +261,7 @@ extension Render3DGPUTests { pathTracingPresentation: Bool = false ) async throws -> Scene { let backend = GoldenFrame.activeBackend ?? .metal - let requested: RenderBackendType = backend == .webgpu ? .webgpu : .metal + let requested: RenderBackendType = backend == .saga ? .saga : (backend == .webgpu ? .webgpu : .metal) unsafe RenderEngine.configurations.preferredBackend = requested try RenderEngine.setupRenderEngine() // A silent fallback to another backend would make the references meaningless. diff --git a/Tests/AdaEngineTests/Render3DMeshletTests.swift b/Tests/AdaEngineTests/Render3DMeshletTests.swift index 57d0e0c26..f36cd52a2 100644 --- a/Tests/AdaEngineTests/Render3DMeshletTests.swift +++ b/Tests/AdaEngineTests/Render3DMeshletTests.swift @@ -42,7 +42,7 @@ struct Render3DMeshletSettingsTests { } #if canImport(WebGPU) && !WASM && !os(Android) - @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1"), arguments: [0, 1, 2, 3]) + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1"), arguments: [0, 1, 2, 3]) func mapShadersCompileFreshForEveryAttachmentLayout(variant: Int) async throws { let root = ProcessInfo.processInfo.environment["ADAENGINE_SHADER_SOURCE_ROOT"].map { URL(fileURLWithPath: $0) } ?? URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() diff --git a/Tests/AdaEngineTests/Render3DPathTracingTests.swift b/Tests/AdaEngineTests/Render3DPathTracingTests.swift index 4155c88fb..d055e94f0 100644 --- a/Tests/AdaEngineTests/Render3DPathTracingTests.swift +++ b/Tests/AdaEngineTests/Render3DPathTracingTests.swift @@ -13,7 +13,7 @@ import Testing @Suite("Path tracing settings and BVH") struct Render3DPathTracingSettingsTests { #if canImport(WebGPU) && !WASM && !os(Android) - @Test(.enabled(if:ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1")) + @Test(.enabled(if:ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) func pathShadersCompileFreshWithoutAGPU() async throws { let root = URL(fileURLWithPath:#filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() for name in ["path_tracing_3d.glsl","path_tracing_composite_3d.glsl"] { diff --git a/Tests/AdaEngineTests/Render3DSagaNativeTests.swift b/Tests/AdaEngineTests/Render3DSagaNativeTests.swift new file mode 100644 index 000000000..6725a1273 --- /dev/null +++ b/Tests/AdaEngineTests/Render3DSagaNativeTests.swift @@ -0,0 +1,85 @@ +#if os(macOS) || os(Windows) || os(Linux) + @testable import AdaCorePipelines + @_spi(Internal) import AdaRender + import AdaUtils + import Foundation + import Math + import Testing + + private let compareSagaWithMetal = ProcessInfo.processInfo.environment["ADAENGINE_COMPARE_SAGA_METAL"] == "1" + + extension Render3DGPUTests { + /// Renders the real 3D pipeline (`flat3d.wesl` and the other WESL shaders) through SAGA's native runtime. + /// Run with `ADAENGINE_GOLDEN_SAGA=1 ADAENGINE_SAGA_NATIVE=1 swift test --filter Render3DGPUTests/SagaNative`. + @Suite("SAGA native") + @MainActor + struct SagaNative { + private static var legacyReference: URL { + GoldenFrame.outputDirectory.appendingPathComponent("legacy-metal-spheres.png") + } + + #if os(macOS) + @Test(.enabled(if: compareSagaWithMetal && GoldenFrame.activeBackend == .metal)) + func captureLegacyMetalReferenceForSAGA() async throws { + let scene = try await DebugViews.makeScene() + let image = try await scene.render(.none, frames: 4) + try image.writePNG(to: Self.legacyReference) + } + #endif + + @Test(.enabled(if: GoldenFrame.activeBackend == .saga)) + func theLitSphereRowRendersWithTheWESLShaders() async throws { + let scene = try await DebugViews.makeScene() + let image = try await scene.render(.none, frames: 4) + try image.writePNG(to: GoldenFrame.outputDirectory.appendingPathComponent("saga-native-spheres.png")) + if compareSagaWithMetal { + let reference = try Image(contentsOf: Self.legacyReference) + let comparison = GoldenFrame.compare(image, reference, tolerance: .metal) + #expect(comparison.passes(.metal), + "SAGA differs from the fresh Metal frame: \(comparison.differingPixels) pixels, max step \(comparison.largestStep)") + } + + let background = Color(red: 0.55, green: 0.7, blue: 0.9) + func isBackground(_ color: Color) -> Bool { + abs(color.red - background.red) < 0.06 && abs(color.green - background.green) < 0.06 && abs(color.blue - background.blue) < 0.06 + } + // The camera looks along +Z at four spheres whose centers cross the middle row. + var shaded = 0 + var lit = 0 + var luminance: Float = 0 + let row = image.height / 2 + for x in 0.. pixel.blue { lit += 1 } + } + } + #expect(shaded > image.width / 4, "The spheres must cover a good share of the middle row: \(shaded)") + #expect(lit > 0, "Their warm base color must show: \(lit)") + #expect(luminance > 0.05, "The spheres must be lit: \(luminance)") + } + + /// A Shader Graph material goes through the WGSL generator and the `flat3d.wesl` template at runtime. + @Test(.enabled(if: GoldenFrame.activeBackend == .saga)) + func aShaderGraphMaterialRendersThroughTheWESLTemplate() async throws { + let scene = try await DebugViews.makeScene() + let ground = try #require(scene.groundMaterial) + let graph = ShaderGraph(target: .pbrSurface, output: "output", nodes: [ + .init(id: "tint", operation: .parameter, type: .color, value: [0.9, 0.08, 0.08, 1], reference: "tint"), + .init(id: "output", operation: .output, inputs: ["baseColor": .init("tint")]), + ]) + let instance = try ShaderGraphMaterial(source: .init(graph: graph)) + ground.graphMaterial = instance + let image = try await scene.render(.none, frames: 4) + try image.writePNG(to: GoldenFrame.outputDirectory.appendingPathComponent("saga-native-graph.png")) + #expect(instance.compilationError == nil, "\(instance.compilationError ?? "")") + #expect(instance.hasPreparedPipeline) + // The floor fills the bottom of the frame: its graph color is red, the unlit original is gray-green. + let pixel = image.getPixel(x: image.width / 2, y: image.height - 20) + #expect(pixel.red > pixel.green * 2 && pixel.red > pixel.blue, "Graph color must reach the floor: \(pixel)") + } + } + } +#endif diff --git a/Tests/AdaEngineTests/SpriteMetalRenderingTests.swift b/Tests/AdaEngineTests/SpriteMetalRenderingTests.swift index c37a0dabc..e8d4fcac7 100644 --- a/Tests/AdaEngineTests/SpriteMetalRenderingTests.swift +++ b/Tests/AdaEngineTests/SpriteMetalRenderingTests.swift @@ -53,7 +53,8 @@ struct SpriteMetalRenderingTests { #expect(image.getPixel(x: 0, y: 0) == .red) #expect(image.getPixel(x: 2, y: 1) == .red) let samplingOnly = Texture2D(image: Image(width: 1, height: 1, color: .white)) - #expect(try await device.readImage(from: samplingOnly) == nil) + let sampledImage = try #require(try await device.readImage(from: samplingOnly)) + #expect(sampledImage.getPixel(x: 0, y: 0) == .white) } #if os(macOS) diff --git a/Tests/AdaEngineTests/WESLGradientPipelineTests.swift b/Tests/AdaEngineTests/WESLGradientPipelineTests.swift new file mode 100644 index 000000000..deb7f0365 --- /dev/null +++ b/Tests/AdaEngineTests/WESLGradientPipelineTests.swift @@ -0,0 +1,22 @@ +import AdaCorePipelines +@testable import AdaRender +import Foundation +import Testing + +@MainActor +struct WESLGradientPipelineTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) + func gradientUsesLinkedWESLOnNativeWebGPU() throws { + unsafe RenderEngine.configurations.preferredBackend = .webgpu + try RenderEngine.setupRenderEngine() + var pipelines = RenderPipelines(configurator: LinearGradientPipeline()) + let pipeline = pipelines.pipeline(device: unsafe RenderEngine.shared.renderDevice).descriptor + guard case .code(let source) = pipeline.vertex.source else { + Issue.record("Expected WGSL for the gradient pipeline") + return + } + #expect(source.contains("ada_wesl_"), "Expected the bundled gradient pipeline to link WESL") + #expect(pipeline.vertex.reflectionData.descriptorSets.first?.uniformsBuffers[2] != nil) + #expect(pipeline.fragment?.reflectionData.descriptorSets.first?.uniformsBuffers[0] != nil) + } +} diff --git a/Tests/AdaEngineTests/WESLShadowPipelineTests.swift b/Tests/AdaEngineTests/WESLShadowPipelineTests.swift new file mode 100644 index 000000000..cf87a355f --- /dev/null +++ b/Tests/AdaEngineTests/WESLShadowPipelineTests.swift @@ -0,0 +1,120 @@ +#if canImport(WebGPU) && !os(WASI) && !os(Android) +import AdaAssets +@testable import AdaRender +@testable import AdaUI +import Foundation +import Math +import Testing + +extension Render3DGPUTests { + @MainActor + struct WESLShadowPipelineTests { + @Test + func legacySourcesRemainAvailableAndModuleFailuresPropagate() throws { + let legacy = CustomMaterial(UIShadowMaterial()) + #expect(legacy.shaderSource.getSource(for: .vertex) != nil) + #expect(legacy.shaderSource.getSource(for: .fragment) != nil) + let failed = CustomMaterial(FailingLinkedMaterial()) + #expect(throws: HookFailure.unavailable) { + try failed.makeShaderModule(defines: []) + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) + func customMaterialRetainsLinkedModuleAndDistinctIdentity() async throws { + unsafe RenderEngine.configurations.preferredBackend = .webgpu + try RenderEngine.setupRenderEngine() + let material = CustomMaterial(LinkedShadowMaterial()) + let module = try material.makeShaderModule(defines: []) + #expect(module.getShader(for: .vertex)?.entryPoint == "ui_shadow_vertex") + #expect(module === (try material.makeShaderModule(defines: []))) + #expect(material.shaderSource.stages.isEmpty) + let storage = MaterialStorageData() + storage.updateUniformBuffers(from: module) + unsafe MaterialStorage.shared.setMaterialData(storage, for: material) + material.parameters = UIShadowParameters(color: .black, geometry: Vector4(10, 20, 30, 4), style: Vector4(1, 2, 3, 0.5)) + material.update() + let gpuBuffer = try #require(storage.uniformBufferSet["UIShadowMaterial"]) + let bytes = try await gpuBuffer.readData() + let uploaded = bytes.withUnsafeBytes { unsafe $0.loadUnaligned(as: UIShadowParameters.self) } + #expect(uploaded.geometry == Vector4(10, 20, 30, 4)) + #expect(uploaded.style == Vector4(1, 2, 3, 0.5)) + let other = CustomMaterial(LinkedShadowMaterial()) + #expect(material != other) + #expect(Set([material, other]).count == 2) + #expect(throws: (any Error).self) { + try material.makeShaderModule(defines: [.init(name: "UNSUPPORTED", value: "1")]) + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) + func shadowModuleLoadsBothStagesAndPreservesBindings() throws { + unsafe RenderEngine.configurations.preferredBackend = .webgpu + try RenderEngine.setupRenderEngine() + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let file = root.appendingPathComponent("Sources/AdaUI/Assets/Shaders/ui_shadow.wesl") + let module = try ShaderModule.create(from: file) + #expect(Set(module.stages) == [.vertex, .fragment]) + let vertex = try #require(module.getShader(for: .vertex)) + let fragment = try #require(module.getShader(for: .fragment)) + #expect(vertex.entryPoint == "ui_shadow_vertex") + #expect(fragment.entryPoint == "ui_shadow_fragment") + let vertexBindings = try #require(vertex.reflectionData.descriptorSets.first) + let fragmentBindings = try #require(fragment.reflectionData.descriptorSets.first) + #expect(Set(vertexBindings.uniformsBuffers.keys) == [2]) + #expect(Set(fragmentBindings.uniformsBuffers.keys) == [0]) + // These sizes must come from the validated shader, never from a host-stride override. + #expect(vertexBindings.uniformsBuffers[2]?.size == 192) + #expect(fragmentBindings.uniformsBuffers[0]?.size == 48) + #expect(fragmentBindings.sampledImages.isEmpty) + #expect(fragmentBindings.samplers.isEmpty) + } + } +} + +private struct LinkedShadowMaterial: UIShaderMaterial { + @Uniform(binding: 0, propertyName: "UIShadowMaterial") var parameters: UIShadowParameters + + init() { + parameters = UIShadowParameters(color: .black, geometry: .zero, style: .zero) + } + + static func shaderModule() throws -> ShaderModule? { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let compiled = try ShaderModule.create(from: root.appendingPathComponent("Sources/AdaUI/Assets/Shaders/ui_shadow.wesl")) + // Explicit fixture ABI: production member/name mapping is not implemented yet. + // This verifies complete-module passthrough and upload, not production shadow material reflection. + var reflection = ShaderReflectionData() + let uniform = ShaderResource.ShaderBuffer( + name: "UIShadowMaterial", + size: MemoryLayout.stride, + shaderStage: .fragment, + binding: 0, + resourceAccess: .read, + members: [:] + ) + reflection.shaderBuffers[uniform.name] = uniform + reflection.descriptorSets = [.init()] + reflection.descriptorSets[0].uniformsBuffers[0] = uniform + return ShaderModule( + shaders: [.vertex: try #require(compiled.getShader(for: .vertex)), .fragment: try #require(compiled.getShader(for: .fragment))], + reflectionData: reflection + ) + } + + static func fragmentShader() throws -> AssetHandle { + throw LinkedMaterialError.legacySourceMustNotBeRequested + } + + private enum LinkedMaterialError: Error { case legacySourceMustNotBeRequested } +} + +private enum HookFailure: Error { case unavailable } + +private struct FailingLinkedMaterial: UIShaderMaterial { + static func shaderModule() throws -> ShaderModule? { throw HookFailure.unavailable } + static func fragmentShader() throws -> AssetHandle { throw HookFailure.unavailable } +} +#endif diff --git a/Tests/AdaEngineTests/WESLUIPipelineTests.swift b/Tests/AdaEngineTests/WESLUIPipelineTests.swift new file mode 100644 index 000000000..00b8a0990 --- /dev/null +++ b/Tests/AdaEngineTests/WESLUIPipelineTests.swift @@ -0,0 +1,78 @@ +@testable import AdaCorePipelines +@testable import AdaRender +import AdaSprite +import Foundation +import Testing + +@MainActor +struct WESLUIPipelineTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) + func uiPrimitivesUseLinkedWESLAndPreserveBindings() throws { + unsafe RenderEngine.configurations.preferredBackend = .webgpu + try RenderEngine.setupRenderEngine() + let device = unsafe RenderEngine.shared.renderDevice + var quads = RenderPipelines(configurator: QuadPipeline()) + var circles = RenderPipelines(configurator: CirclePipeline()) + var lines = RenderPipelines(configurator: LinePipeline()) + let quad = quads.pipeline(device: device).descriptor + for pipeline in [quad, circles.pipeline(device: device).descriptor, lines.pipeline(device: device).descriptor] { + guard case .code(let source) = pipeline.vertex.source else { + Issue.record("Expected WGSL for the UI pipeline") + continue + } + #expect(source.contains("ada_wesl_"), "Expected the UI pipeline to link WESL") + #expect(pipeline.vertex.reflectionData.descriptorSets.first?.uniformsBuffers[2] != nil) + } + #expect(quad.fragment?.reflectionData.descriptorSets.first?.sampledImages[0] != nil) + #expect(quad.fragment?.reflectionData.descriptorSets.first?.samplers[1] != nil) + + let lights = Light2DRenderPipelines(device: device) + for pipeline in [lights.compositePipeline, lights.pointLightPipeline, lights.directionalLightPipeline] { + guard case .code(let source) = pipeline.descriptor.vertex.source else { + Issue.record("Expected WGSL for the 2D light pipeline") + continue + } + #expect(source.contains("ada_wesl_"), "Expected the 2D light pipeline to link WESL") + } + #expect(lights.compositePipeline.descriptor.fragment?.reflectionData.descriptorSets.first?.uniformsBuffers[3] != nil) + #expect(lights.pointLightPipeline.descriptor.fragment?.reflectionData.descriptorSets.first?.uniformsBuffers[4] != nil) + #expect(lights.directionalLightPipeline.descriptor.fragment?.reflectionData.descriptorSets.first?.uniformsBuffers[2] != nil) + + let upscale = UpscalePipeline(device: device).renderPipeline + let presentation = PixelPerfectPipelines(device: device, outputPipeline: upscale).intermediate + for pipeline in [upscale, presentation] { + guard case .code(let source) = pipeline.descriptor.vertex.source else { + Issue.record("Expected WGSL for fullscreen presentation") + continue + } + #expect(source.contains("ada_wesl_"), "Expected fullscreen presentation to link WESL") + } + + var temporal = Temporal3DPipelines(device: device) + var screenQuality = ScreenQuality3DPipelines(device: device) + let depth = temporal.depthPipeline(device: device) + for pipeline in [temporal.skyPipeline(device: device), screenQuality.aaPipeline(device: device), depth] { + guard case .code(let source) = pipeline.descriptor.vertex.source else { + Issue.record("Expected WGSL for the 3D screen pass") + continue + } + #expect(source.contains("ada_wesl_"), "Expected the 3D screen pass to link WESL") + } + if case .code(let source) = depth.descriptor.fragment?.source { + #expect(source.contains("texture_depth_2d")) + } else { + Issue.record("Expected a depth fragment stage") + } + + var irradiance = DynamicIrradiance3DViews() + let fragment = try #require(irradiance.getPipeline(device).descriptor.fragment) + guard case .code(let irradianceSource) = fragment.source else { + Issue.record("Expected linked WGSL for dynamic irradiance") + return + } + #expect(irradianceSource.contains("textureLoad")) + let bindings = try #require(fragment.reflectionData.descriptorSets.first) + #expect(Set(bindings.sampledImages.keys) == [0, 1, 2, 3, 6, 26]) + #expect(bindings.samplers.isEmpty) + } +} diff --git a/Tests/AdaRenderTests/BuiltInWESLShaderTests.swift b/Tests/AdaRenderTests/BuiltInWESLShaderTests.swift new file mode 100644 index 000000000..85804d8cd --- /dev/null +++ b/Tests/AdaRenderTests/BuiltInWESLShaderTests.swift @@ -0,0 +1,158 @@ +#if canImport(AdaWGSLCompiler) && !os(WASI) && !os(Android) + import AdaWGSLCompiler + import Foundation + import Testing + + /// Links every built-in shader that has a WESL source and checks its stages and uniform block layout, + /// so a port cannot silently change entry points or the byte size the materials upload. + struct BuiltInWESLShaderTests { + struct Expectation: CustomTestStringConvertible, Sendable { + let path: String + let entryPoints: [String: String] + /// Byte size of each group 0 uniform block the fragment reaches, by binding. + let uniformBytes: [UInt32: UInt64] + /// Feature sets to link; each must keep the same entry points and uniform layout. + var featureSets: [[String]] = [[]] + + var testDescription: String { path } + } + + /// Vertex layouts a 2D mesh can have; the canvas materials select the vertex stage from these. + static let meshFeatureSets: [[String]] = [ + [], + ["VERTEX_POSITIONS"], + ["VERTEX_POSITIONS", "VERTEX_UVS"], + ["VERTEX_POSITIONS", "VERTEX_COLORS"], + ["VERTEX_POSITIONS", "VERTEX_NORMALS", "VERTEX_UVS", "VERTEX_COLORS"], + ] + + /// Variants the 3D pipeline requests: surface kind, render targets, layered materials and baked lighting. + static let flat3DFeatureSets: [[String]] = [ + [], + ["ADA_TERRAIN"], + ["ADA_WIREFRAME"], + ["ADA_MORPH"], + ["ADA_HYBRID_REFLECTION"], + ["ADA_DIFFUSE_SURFACE"], + ["ADA_HYBRID_REFLECTION", "ADA_DIFFUSE_SURFACE", "ADA_PATH_TRACING_SURFACE"], + ["ADA_CLEARCOAT", "ADA_CLEARCOAT_TEXTURE", "ADA_CLEARCOAT_ROUGHNESS_TEXTURE", "ADA_CLEARCOAT_NORMAL_TEXTURE"], + ["ADA_SHEEN", "ADA_SHEEN_COLOR_TEXTURE", "ADA_SHEEN_ROUGHNESS_TEXTURE"], + ["ADA_ANISOTROPY", "ADA_ANISOTROPY_TEXTURE", "ADA_SPECULAR_TEXTURE", "ADA_SPECULAR_COLOR_TEXTURE"], + ["ADA_LIGHTMAP", "ADA_IRRADIANCE_GRID"], + ["ADA_TERRAIN", "ADA_CLEARCOAT", "ADA_ANISOTROPY", "ADA_LIGHTMAP"], + ] + + static let expectations: [Expectation] = [ + Expectation( + path: "Sources/AdaUI/Assets/Shaders/ui_shadow.wesl", + entryPoints: ["ui_shadow_vertex": "vertex", "ui_shadow_fragment": "fragment"], + uniformBytes: [0: 48] + ), + Expectation( + path: "Editor/Sources/AdaEditor/Assets/Shaders/agent_activity.wesl", + entryPoints: ["editor_agent_activity_vertex": "vertex", "editor_agent_activity_fragment": "fragment"], + uniformBytes: [0: 48] + ), + Expectation( + path: "Editor/Sources/AdaEditor/Assets/Shaders/mobile_voice_orb.wesl", + entryPoints: ["mobile_voice_orb_vertex": "vertex", "mobile_voice_orb_fragment": "fragment"], + uniformBytes: [0: 32] + ), + Expectation( + path: "Sources/AdaCorePipelines/Assets/Shaders/glass.wesl", + entryPoints: ["glass_vertex": "vertex", "glass_fragment": "fragment"], + uniformBytes: [:] + ), + Expectation( + path: "Sources/AdaRender/Assets/Shaders/mesh2d/mesh2d.wesl", + entryPoints: ["mesh_vertex": "vertex", "mesh_fragment": "fragment"], + uniformBytes: [:], + featureSets: Self.meshFeatureSets + ), + Expectation( + path: "Sources/AdaRender/Assets/Shaders/Materials/color_canvas_material.wesl", + entryPoints: ["mesh_vertex": "vertex", "color_canvas_mesh_fragment": "fragment"], + uniformBytes: [0: 16], + featureSets: Self.meshFeatureSets + ), + Expectation( + path: "Sources/AdaRender/Assets/Shaders/Materials/circle_canvas_material.wesl", + entryPoints: ["mesh_vertex": "vertex", "circle_canvas_mesh_fragment": "fragment"], + uniformBytes: [0: 32], + featureSets: Self.meshFeatureSets + ), + Expectation( + path: "Sources/AdaCorePipelines/Assets/Shaders/flat3d.wesl", + entryPoints: ["flat3d_vertex": "vertex", "flat3d_fragment": "fragment"], + uniformBytes: [1: 272, 15: 656], + featureSets: Self.flat3DFeatureSets + ), + Expectation( + path: "Sources/AdaCorePipelines/Assets/Shaders/path_tracing_3d.wesl", + entryPoints: ["path_vertex": "vertex", "path_fragment": "fragment"], + uniformBytes: [4: 400] + ), + Expectation( + path: "Sources/AdaCorePipelines/Assets/Shaders/particles_3d.wesl", + entryPoints: ["particles_vertex": "vertex", "particles_fragment": "fragment"], + uniformBytes: [4: 624, 5: 1552], + featureSets: [[], ["PARTICLE_INDIRECT"], ["PARTICLE_INDIRECT", "PARTICLE_REFLECTION_RESPONSE"]] + ), + ] + + private static var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + } + + private func link(_ expectation: Expectation, features: [String]) throws -> WESLHostCompiler.Artifact { + let root = Self.repositoryRoot + let source = root.appendingPathComponent(expectation.path) + return try WESLHostCompiler.compile( + sourceFile: source, + packageRoot: source.deletingLastPathComponent(), + features: Dictionary(uniqueKeysWithValues: features.map { ($0, true) }), + dependencies: ["ada_engine": root.appendingPathComponent("Sources/AdaRender/Assets/Shaders/WESL")] + ) + } + + @Test(arguments: expectations) + func linksWithTheExpectedEntryPointsAndMaterialLayout(expectation: Expectation) throws { + for features in expectation.featureSets { + let module = try link(expectation, features: features) + let found = Dictionary(uniqueKeysWithValues: module.entryPoints.map { ($0.name, $0.stage) }) + #expect(found == expectation.entryPoints, "features: \(features)") + + let fragment = try #require(module.entryPoints.first { $0.stage == "fragment" }) + let layouts = try WESLBackendCompiler.webGPUUniformBuffers(module, entryPoint: fragment) + for (binding, bytes) in expectation.uniformBytes { + let layout = try #require(layouts.first { $0.group == 0 && $0.binding == binding }, "binding \(binding), features: \(features)") + #expect(layout.minimumByteSize == bytes, "binding \(binding), features: \(features)") + } + } + } + + @Test func particleFeaturesControlTheExtraColorTargets() throws { + let particles = try #require(Self.expectations.first { $0.path.hasSuffix("particles_3d.wesl") }) + let plain = try link(particles, features: []).wgsl + let indirect = try link(particles, features: ["PARTICLE_INDIRECT"]).wgsl + let both = try link(particles, features: ["PARTICLE_INDIRECT", "PARTICLE_REFLECTION_RESPONSE"]).wgsl + #expect(!plain.contains("indirect") && !plain.contains("reflectionResponse")) + #expect(indirect.contains("indirect") && !indirect.contains("reflectionResponse")) + #expect(both.contains("indirect") && both.contains("reflectionResponse")) + } + + @Test(arguments: expectations) + func compilesToEveryNativeBackendStage(expectation: Expectation) throws { + for features in expectation.featureSets { + let module = try link(expectation, features: features) + do { + let stages = try WESLBackendCompiler.compile(module, to: .msl) + #expect(Set(stages.map(\.stage)) == ["vertex", "fragment"], "features: \(features)") + } catch { + Issue.record("features \(features): \(error)") + } + } + } + } +#endif diff --git a/Tests/AdaRenderTests/OrderedGPUWorkQueueTests.swift b/Tests/AdaRenderTests/OrderedGPUWorkQueueTests.swift new file mode 100644 index 000000000..c72fe51b0 --- /dev/null +++ b/Tests/AdaRenderTests/OrderedGPUWorkQueueTests.swift @@ -0,0 +1,68 @@ +import Synchronization +import Testing + +@testable import AdaRender + +@Suite +struct OrderedGPUWorkQueueTests { + private struct Boom: Error {} + + @Test + func workRunsInEnqueueOrderAgainstTheAsyncCreatedContext() async { + let log = Mutex<[Int]>([]) + let queue = OrderedGPUWorkQueue(context: { 7 }, onError: { _ in }) + for index in 0..<50 { + queue.enqueue { context in + // Yield so an unordered implementation interleaves. + await Task.yield() + log.withLock { $0.append(index * context) } + } + } + await queue.drain() + #expect(log.withLock { $0 } == (0..<50).map { $0 * 7 }) + } + + @Test + func slotsFilledByEarlierWorkAreVisibleToLaterWork() async { + let queue = OrderedGPUWorkQueue(context: {}, onError: { _ in }) + let slot = ResourceSlot() + let observed = Mutex(nil) + queue.enqueue { _ in + await Task.yield() + slot.fulfill(42) + } + queue.enqueue { _ in observed.withLock { $0 = try? slot.get() } } + await queue.drain() + #expect(observed.withLock { $0 } == 42) + } + + @Test + func failedWorkIsReportedAndDoesNotStopLaterWork() async { + let errors = Mutex(0) + let ran = Mutex(false) + let queue = OrderedGPUWorkQueue(context: {}, onError: { _ in errors.withLock { $0 += 1 } }) + queue.enqueue { _ in throw Boom() } + queue.enqueue { _ in ran.withLock { $0 = true } } + await queue.drain() + #expect(errors.withLock { $0 } == 1) + #expect(ran.withLock { $0 }) + } + + @Test + func contextFailureReportsOnceAndSkipsWorkWithoutHanging() async { + let errors = Mutex(0) + let ran = Mutex(false) + let queue = OrderedGPUWorkQueue(context: { throw Boom() }, onError: { _ in errors.withLock { $0 += 1 } }) + queue.enqueue { _ in ran.withLock { $0 = true } } + queue.enqueue { _ in ran.withLock { $0 = true } } + await queue.drain() + #expect(errors.withLock { $0 } == 1) + #expect(!ran.withLock { $0 }) + } + + @Test + func unfulfilledSlotThrows() { + let slot = ResourceSlot() + #expect(throws: ResourceSlot.SlotError.self) { try slot.get() } + } +} diff --git a/Tests/AdaRenderTests/SAGACommandCompletionTests.swift b/Tests/AdaRenderTests/SAGACommandCompletionTests.swift new file mode 100644 index 000000000..1b9b40383 --- /dev/null +++ b/Tests/AdaRenderTests/SAGACommandCompletionTests.swift @@ -0,0 +1,118 @@ +#if SAGA_METAL + @testable import AdaRender + import AdaUtils + import Math + import SAGABackend + import SAGACore + import SAGAShaderIR + import SAGATypes + import Synchronization + import Testing + + struct SAGACommandCompletionTests { + @Test + func completionRunsWhenTheDeviceCannotBeCreated() async throws { + let context = try Self.unavailableContext() + let commands = SAGACommandBuffer(context: context) + let completions = Mutex(0) + commands.addCompletedHandler { completions.withLock { $0 += 1 } } + commands.addCompletedTimingHandler { _ in completions.withLock { $0 += 1 } } + commands.commit() + await context.queue.drain() + + #expect(completions.withLock { $0 } == 2, "A failed device must still notify both handlers") + #expect(context.errorLog.all.count == 1) + } + + @Test + func retirementRemovesAnAttachedSurfaceWhenTheDeviceCannotBeCreated() async throws { + let context = try Self.unavailableContext() + let window = SAGAWindow( + context: context, + windowId: WindowID(), + size: SizeInt(width: 16, height: 16), + scaleFactor: 1, + pixelFormat: .bgra8, + surfaceID: 11 + ) + context.queue.enqueue { _ in } + let removed = Mutex<[UInt32]>([]) + window.retire { id in removed.withLock { $0.append(id) } } + await context.queue.drain() + + #expect(removed.withLock { $0 } == [11], "An attached surface must retire even if device setup failed") + #expect(context.errorLog.all.count == 1) + } + + private static func unavailableContext() throws -> SAGAContext { + SAGAContext( + capabilities: try DeviceCapabilities(maxBufferSize: 4096, maxTextureDimension2D: 64), + supportsPresentation: true, + shaderOutput: .msl, + debugErrors: false, + makeDevice: { throw GPUError.adapterUnavailable } + ) + } + + @Test(arguments: [false, true]) + func completionWaitsForDeviceRetirement(emptyFence: Bool) async throws { + let capabilities = try DeviceCapabilities(maxBufferSize: 4096, maxTextureDimension2D: 64) + let backend = HeldSubmissionBackend(capabilities: capabilities) + let context = SAGAContext( + capabilities: capabilities, + supportsPresentation: false, + shaderOutput: .msl, + debugErrors: false, + makeDevice: { try await Device.make(backend: backend) } + ) + let slot = context.createBuffer(.init(size: 4, usage: .copyDestination)) + let commands = SAGACommandBuffer(context: context) + commands.append { .writeBytes(buffer: try slot.get(), offset: 0, bytes: [1, 2, 3, 4]) } + let fence = emptyFence ? SAGACommandBuffer(context: context) : commands + let callbacksSawRetirement = Mutex<[Bool]>([]) + fence.addCompletedHandler { + callbacksSawRetirement.withLock { $0.append(backend.isRetired) } + } + fence.addCompletedTimingHandler { _ in + callbacksSawRetirement.withLock { $0.append(backend.isRetired) } + } + commands.commit() + if emptyFence { + fence.commit() + } + + try await backend.started.wait(timeout: .seconds(2)) + // Give the adapter an opportunity to dispatch an early callback while the backend is held. + try await Task.sleep(for: .milliseconds(150)) + await backend.release.finish(.success(())) + await context.queue.drain() + + #expect(callbacksSawRetirement.withLock { $0 } == [true, true], "Callbacks must observe completed device work") + #expect(context.errorLog.all.isEmpty) + } + } + + private final class HeldSubmissionBackend: CommandBackendDevice { + let capabilities: DeviceCapabilities + let started = BackendCompletion() + let release = BackendCompletion() + private let retired = Mutex(false) + + var isRetired: Bool { retired.withLock { $0 } } + + init(capabilities: DeviceCapabilities) { + self.capabilities = capabilities + } + + func allocateBuffer(id: ResourceID, descriptor: BufferDescriptor) async throws {} + func destroyBuffer(id: ResourceID) async {} + func submit(_ submission: Submission) async throws {} + + func submitCommands(_ submission: Submission, commands: [BufferCommand]) async throws -> [[UInt32]] { + await started.finish(.success(())) + try await release.wait(timeout: .seconds(5)) + retired.withLock { $0 = true } + return [] + } + } +#endif diff --git a/Tests/AdaRenderTests/SAGAMetalWindowTests.swift b/Tests/AdaRenderTests/SAGAMetalWindowTests.swift new file mode 100644 index 000000000..a3c341688 --- /dev/null +++ b/Tests/AdaRenderTests/SAGAMetalWindowTests.swift @@ -0,0 +1,87 @@ +#if os(macOS) && SAGA_METAL + @testable import AdaRender + import AdaUtils + import AppKit + import Foundation + import Math + import MetalKit + import Testing + + private let sagaMetalWindowSmokeEnabled = ProcessInfo.processInfo.environment["ADAENGINE_SAGA_METAL_WINDOW_SMOKE"] == "1" + + @MainActor + struct SAGAMetalWindowTests { + @Test(.enabled(if: sagaMetalWindowSmokeEnabled)) + func presentsAndResizesAnEngineDrawable() async throws { + _ = NSApplication.shared + let view = MTKView(frame: NSRect(x: 0, y: 0, width: 64, height: 64), device: nil) + view.isPaused = true + let window = NSWindow(contentRect: view.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + window.orderFrontRegardless() + defer { window.close() } + + try RenderEngine.setupRenderEngine() + try #require(unsafe RenderEngine.shared.type == .saga, "The default renderer must be SAGA Metal.") + let backend = try SAGAMetalRenderBackend() + let id = WindowID() + var feedback = 0 + var displayed: [SizeInt] = [] + let first = SizeInt(width: 64, height: 64) + try backend.createWindow(id, for: view, size: first) { frame in + feedback += 1 + if frame.presentedTime > 0 { + displayed.append(SizeInt(width: Int(frame.width), height: Int(frame.height))) + } + } + let device = backend.renderDevice + let swapchain = try #require(device.createSwapchain(from: id)) + let sampler = device.createSampler(from: SamplerDescriptor()) + + func submitFrame(_ size: SizeInt) async throws { + let previousFeedback = feedback + let drawable = try #require(swapchain.getNextDrawable(device)) + let texture = Texture2D(gpuTexture: drawable.texture, sampler: sampler, size: size) + let commands = device.createCommandQueue().makeCommandBuffer() + let pass = commands.beginRenderPass(RenderPassDescriptor(colorAttachments: [ + .init( + texture: texture, + operation: .init(loadAction: .clear, storeAction: .store), + clearColor: Color(red: 0.25, green: 0.5, blue: 0.75) + ) + ])) + pass.endRenderPass() + commands.commit() + try drawable.present() + let image = try #require(try await device.readImage(from: texture)) + let pixel = image.getPixel(x: image.width / 2, y: image.height / 2) + #expect(abs(pixel.red - 0.25) < 0.02) + #expect(abs(pixel.green - 0.5) < 0.02) + #expect(abs(pixel.blue - 0.75) < 0.02) + for _ in 0..<50 where feedback == previousFeedback { + try await Task.sleep(for: .milliseconds(20)) + } + } + + func present(_ size: SizeInt) async throws { + // WindowServer can discard the first frame while a new window or drawable size is being shown. + for _ in 0..<4 where displayed.last != size { + try await submitFrame(size) + } + #expect(displayed.last == size, "No frame of size \(size) reached a display.") + } + + try backend.resizeWindow(id, newSize: first, scaleFactor: 1) + try await present(first) + let second = SizeInt(width: 96, height: 72) + window.setContentSize(NSSize(width: second.width, height: second.height)) + try backend.resizeWindow(id, newSize: second, scaleFactor: 1) + try await present(second) + try backend.destroyWindow(id) + await backend.context.queue.drain() + #expect(swapchain.getNextDrawable(device) == nil) + #expect(backend.context.errorLog.all.isEmpty, "\(backend.context.errorLog.all)") + } + } +#endif diff --git a/Tests/AdaRenderTests/SAGAVulkanDeviceTests.swift b/Tests/AdaRenderTests/SAGAVulkanDeviceTests.swift new file mode 100644 index 000000000..7b92998ac --- /dev/null +++ b/Tests/AdaRenderTests/SAGAVulkanDeviceTests.swift @@ -0,0 +1,315 @@ +#if SAGA_NATIVE + import AdaUtils + import Foundation + import Math + import Testing + #if os(Windows) + import WinSDK + #endif + + @_spi(Internal) @testable import AdaRender + + /// Exercises the SAGA adapter on a real Vulkan device. Skipped without a Vulkan adapter. + @Suite(.serialized) + struct SAGAVulkanDeviceTests { + private static var available: Bool { SAGAContext.isVulkanAvailable } + + @Test(.enabled(if: SAGAVulkanDeviceTests.available)) + func buffersRoundTripThroughTheGPU() async throws { + let context = try SAGAContext.vulkan() + let device = SAGARenderDevice(context: context) + let values: [UInt32] = (0..<64).map { $0 &* 2654435761 } + var copy = values + let buffer = unsafe copy.withUnsafeMutableBytes { raw in + unsafe device.createBuffer(label: "round trip", bytes: raw.baseAddress!, length: raw.count, options: .storageShared) + } + let data = try await buffer.readData() + let readBack = data.withUnsafeBytes { Array($0.bindMemory(to: UInt32.self)) } + #expect(readBack == values) + } + + @Test(.enabled(if: SAGAVulkanDeviceTests.available)) + func bufferWritesAtAnOffsetLandWhereExpected() async throws { + let context = try SAGAContext.vulkan() + let device = SAGARenderDevice(context: context) + let buffer = device.createBuffer(label: "offset", length: 64, options: .storageShared) + var word: UInt32 = 0xDEADBEEF + unsafe withUnsafeMutableBytes(of: &word) { unsafe buffer.setData($0.baseAddress!, byteCount: 4, offset: 16) } + let data = try await buffer.readData() + let words = data.withUnsafeBytes { Array($0.bindMemory(to: UInt32.self)) } + #expect(words[4] == 0xDEADBEEF) + #expect(words[0] == 0 && words[5] == 0) + } + + private static let triangleShader = """ + @vertex fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + var corners = array, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + return vec4(corners[index], 0.0, 1.0); + } + @fragment fn fs() -> @location(0) vec4 { return vec4(0.25, 0.5, 0.75, 1.0); } + """ + + @Test(.enabled(if: SAGAVulkanDeviceTests.available)) + func aRenderPassDrawsIntoATextureThatReadsBack() async throws { + let context = try SAGAContext.vulkan(debugErrors: true) + let device = SAGARenderDevice(context: context) + let vertex = Shader(source: Self.triangleShader, entryPoint: "vs", stage: .vertex) + let fragment = Shader(source: Self.triangleShader, entryPoint: "fs", stage: .fragment) + vertex.compiledShader = try device.compileShader(from: vertex) + fragment.compiledShader = try device.compileShader(from: fragment) + let pipeline = device.createRenderPipeline( + from: RenderPipelineDescriptor( + vertex: vertex, + fragment: fragment, + debugName: "fullscreen triangle", + backfaceCulling: false, + depthPixelFormat: .none, + colorAttachments: [.init(format: .rgba8)] + ) + ) + let size = SizeInt(width: 16, height: 16) + let target = device.createTexture( + from: TextureDescriptor( + width: size.width, height: size.height, pixelFormat: .rgba8, + textureUsage: [.renderTarget, .read], textureType: .texture2D + ) + ) + let texture = Texture2D(gpuTexture: target, sampler: device.createSampler(from: SamplerDescriptor()), size: size) + + let commands = device.createCommandQueue().makeCommandBuffer() + let pass = commands.beginRenderPass( + RenderPassDescriptor( + colorAttachments: [ + RenderPassColorAttachmentDescriptor( + texture: texture, + operation: OperationDescriptor(loadAction: .clear, storeAction: .store), + clearColor: .black + ) + ] + ) + ) + pass.setRenderPipelineState(pipeline) + pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) + pass.endRenderPass() + commands.commit() + + let image = try #require(try await device.readImage(from: texture)) + #expect(image.width == 16 && image.height == 16) + let bytes = [UInt8](image.data) + let center = (8 * 16 + 8) * 4 + #expect(abs(Int(bytes[center]) - 64) <= 2) + #expect(abs(Int(bytes[center + 1]) - 128) <= 2) + #expect(abs(Int(bytes[center + 2]) - 191) <= 2) + #expect(bytes[center + 3] == 255) + } + + private static let multipleTargetShader = """ + struct Out { @location(0) a: vec4, @location(1) b: vec4, @location(2) d: vec4, } + @vertex fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4 { + var corners = array, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + return vec4(corners[index], 0.0, 1.0); + } + @fragment fn fs() -> Out { + var out: Out; + out.a = vec4(1.0, 0.0, 0.0, 1.0); + out.b = vec4(0.0, 1.0, 0.0, 1.0); + out.d = vec4(0.0, 0.0, 1.0, 1.0); + return out; + } + """ + + @Test(.enabled(if: SAGAVulkanDeviceTests.available)) + func aPassWritesEveryColorTarget() async throws { + let context = try SAGAContext.vulkan(debugErrors: true) + let device = SAGARenderDevice(context: context) + let vertex = Shader(source: Self.multipleTargetShader, entryPoint: "vs", stage: .vertex) + let fragment = Shader(source: Self.multipleTargetShader, entryPoint: "fs", stage: .fragment) + vertex.compiledShader = try device.compileShader(from: vertex) + fragment.compiledShader = try device.compileShader(from: fragment) + let pipeline = device.createRenderPipeline( + from: RenderPipelineDescriptor( + vertex: vertex, fragment: fragment, debugName: "multiple targets", backfaceCulling: false, + depthPixelFormat: .none, colorAttachments: [.init(format: .rgba8), .init(format: .rgba8), .init(format: .rgba8)] + ) + ) + let size = SizeInt(width: 8, height: 8) + func makeTarget() -> Texture2D { + let gpu = device.createTexture( + from: TextureDescriptor( + width: size.width, height: size.height, pixelFormat: .rgba8, + textureUsage: [.renderTarget, .read], textureType: .texture2D + ) + ) + return Texture2D(gpuTexture: gpu, sampler: device.createSampler(from: SamplerDescriptor()), size: size) + } + let targets = [makeTarget(), makeTarget(), makeTarget()] + let commands = device.createCommandQueue().makeCommandBuffer() + let pass = commands.beginRenderPass( + RenderPassDescriptor( + colorAttachments: targets.map { + RenderPassColorAttachmentDescriptor( + texture: $0, operation: OperationDescriptor(loadAction: .clear, storeAction: .store), clearColor: .black + ) + } + ) + ) + pass.setRenderPipelineState(pipeline) + pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) + pass.endRenderPass() + commands.commit() + + await context.queue.drain() + #expect(context.errorLog.all.isEmpty, "SAGA device errors: \(context.errorLog.all)") + let expected: [[UInt8]] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]] + for (target, color) in zip(targets, expected) { + let image = try #require(try await device.readImage(from: target)) + let bytes = [UInt8](image.data) + let center = (4 * 8 + 4) * 4 + #expect(Array(bytes[center..
HWND { + let className = Array("STATIC".utf16) + [0] + let titleText = Array(title.utf16) + [0] + return try #require( + unsafe className.withUnsafeBufferPointer { classPointer in + unsafe titleText.withUnsafeBufferPointer { titlePointer in + unsafe CreateWindowExW( + 0, classPointer.baseAddress, titlePointer.baseAddress, DWORD(WS_OVERLAPPEDWINDOW), + 0, 0, 96, 96, nil, nil, GetModuleHandleW(nil), nil + ) + } + } + ) + } + let first = try makeWindow("SAGA window one") + let second = try makeWindow("SAGA window two") + defer { + DestroyWindow(first) + DestroyWindow(second) + } + + let backend = try SAGANativeRenderBackend(windowless: false) + let size = SizeInt(width: 64, height: 64) + let ids = [WindowID(), WindowID()] + for (id, hwnd) in zip(ids, [first, second]) { + try backend.createWindow(id, for: WindowsSurface(windowId: id, windowHwnd: UnsafeMutableRawPointer(hwnd)), size: size) + } + + let device = backend.renderDevice + let vertex = Shader(source: Self.triangleShader, entryPoint: "vs", stage: .vertex) + let fragment = Shader(source: Self.triangleShader, entryPoint: "fs", stage: .fragment) + vertex.compiledShader = try device.compileShader(from: vertex) + fragment.compiledShader = try device.compileShader(from: fragment) + let pipeline = device.createRenderPipeline( + from: RenderPipelineDescriptor( + vertex: vertex, fragment: fragment, debugName: "two windows", backfaceCulling: false, + depthPixelFormat: .none, colorAttachments: [.init(format: .bgra8)] + ) + ) + for (index, id) in (ids + ids.prefix(1)).enumerated() { + if index == ids.count { + try backend.destroyWindow(id) + try backend.createWindow(id, for: WindowsSurface(windowId: id, windowHwnd: UnsafeMutableRawPointer(first)), size: size) + } + let swapchain = try #require(device.createSwapchain(from: id)) + let drawable = try #require(swapchain.getNextDrawable(device)) + let texture = Texture2D(gpuTexture: drawable.texture, sampler: device.createSampler(from: SamplerDescriptor()), size: size) + let commands = device.createCommandQueue().makeCommandBuffer() + let pass = commands.beginRenderPass( + RenderPassDescriptor( + colorAttachments: [ + RenderPassColorAttachmentDescriptor( + texture: texture, + operation: OperationDescriptor(loadAction: .clear, storeAction: .store), + clearColor: .black + ) + ] + ) + ) + pass.setRenderPipelineState(pipeline) + pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) + pass.endRenderPass() + commands.commit() + try drawable.present() + } + await backend.context.queue.drain() + #expect(backend.context.errorLog.all.isEmpty, "SAGA device errors: \(backend.context.errorLog.all)") + for id in ids { try backend.destroyWindow(id) } + await backend.context.queue.drain() + } + #endif + } +#endif diff --git a/Tests/AdaRenderTests/ShaderGraphArchiveTests.swift b/Tests/AdaRenderTests/ShaderGraphArchiveTests.swift index d7068443c..257a84159 100644 --- a/Tests/AdaRenderTests/ShaderGraphArchiveTests.swift +++ b/Tests/AdaRenderTests/ShaderGraphArchiveTests.swift @@ -19,9 +19,10 @@ struct ShaderGraphArchiveTests { @Test func invalidArchivesDoNotInstall() throws { var archive = ShaderGraphArchive(graphs: [:], fragments: [], texturePaths: []) - archive.version = 2 - #expect(throws: ShaderGraphDiagnostic.self) { try ShaderGraphRuntimeLibrary.install(archive) } + // A GLSL-era archive is rejected with an explicit migration message. archive.version = 1 + #expect(throws: ShaderGraphDiagnostic.self) { try ShaderGraphRuntimeLibrary.install(archive) } + archive.version = ShaderGraphArchive.currentVersion archive.fragments = [.init(source: "", defines: [:], entryPoint: "main", wgsl: "", reflection: .init())] #expect(throws: ShaderGraphDiagnostic.self) { try ShaderGraphRuntimeLibrary.install(archive) } #expect(throws: ShaderGraphDiagnostic.self) { try ShaderGraphRuntimeLibrary.graph(at: "@res://missing-graph.shadergraph") } diff --git a/Tests/AdaRenderTests/ShaderGraphWGSLTests.swift b/Tests/AdaRenderTests/ShaderGraphWGSLTests.swift new file mode 100644 index 000000000..7292ece7f --- /dev/null +++ b/Tests/AdaRenderTests/ShaderGraphWGSLTests.swift @@ -0,0 +1,157 @@ +#if canImport(AdaWGSLCompiler) && !os(WASI) && !os(Android) + import AdaWGSLCompiler + @testable import AdaRender + import Foundation + import Testing + + /// The WGSL generator must accept every graph the GLSL generator accepts and keep the same binding contract. + @Suite("Generated WGSL shader graph functions", .serialized) + struct ShaderGraphWGSLTests { + private static let operations: [ShaderGraph.Operation] = [ + .subtract, .divide, .power, .minimum, .maximum, .clamp, .saturate, .oneMinus, .sine, .cosine, .absolute, + .fraction, .floor, .step, .smoothstep, .lerp, .add, .multiply, + ] + + private func link(_ program: ShaderGraphProgram) throws -> WESLHostCompiler.Artifact { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("main.wesl") + try """ + \(program.source) + @fragment fn main() -> @location(0) vec4f { + let surface = ada_graph_evaluate(vec2f(0.5)); + return surface.color + vec4f(surface.emission, 0.0) + vec4f(surface.alpha); + } + """.write(to: file, atomically: true, encoding: .utf8) + return try WESLHostCompiler.compile(sourceFile: file, packageRoot: root) + } + + @Test(arguments: operations) + func mathNodesLinkWithScalarAndVectorInputs(_ operation: ShaderGraph.Operation) throws { + for type in [ShaderGraph.ValueType.float, .vector2, .vector3, .vector4, .color] { + var graph = ShaderGraph(target: .sprite, output: "output", nodes: []) + var operationNode = ShaderGraph.Node(id: "effect", operation: operation, type: type) + for port in graph.inputPorts(for: operationNode).keys { + graph.nodes.append(.init(id: port, operation: .constant, value: [0.7])) + operationNode.inputs[port] = .init(port) + } + graph.nodes.append(operationNode) + graph.nodes.append(.init(id: "output", operation: .output, inputs: ["alpha": .init("effect", port: type == .float ? "value" : "x")])) + let program = try graph.compileWGSL() + #expect(program.format == .wgsl) + let module = try link(program) + #expect(module.entryPoints.map(\.name) == ["main"]) + } + } + + @Test func proceduralNoiseAndExplicitVectorChannelsLink() throws { + let graph = ShaderGraph( + target: .pbrSurface, + output: "output", + nodes: [ + .init(id: "x", operation: .constant, value: [-0.25]), + .init(id: "uv", operation: .combine2, type: .vector2, inputs: ["x": .init("x"), "y": .init("x")]), + .init(id: "noise", operation: .noise2D, inputs: ["uv": .init("uv")]), + .init(id: "color", operation: .combine4, type: .vector4, inputs: ["x": .init("noise"), "y": .init("noise"), "z": .init("noise"), "w": .init("x")]), + .init(id: "output", operation: .output, inputs: ["baseColor": .init("color", port: "color")]), + ] + ) + let program = try graph.compileWGSL() + #expect(program.source.contains("ada_graph_noise")) + _ = try link(program) + } + + private func texturedGraph() -> ShaderGraph { + ShaderGraph(target: .pbrSurface, output: "output", nodes: [ + .init(id: "uv", operation: .uv, type: .vector2), + .init(id: "texture", operation: .texture, type: .texture2D, reference: "stone.png"), + .init(id: "sample", operation: .sampleTexture, type: .vector4, inputs: ["texture": .init("texture"), "uv": .init("uv")]), + .init(id: "tint", operation: .parameter, type: .color, value: [1, 1, 1, 1], reference: "tint; invalid shader identifier"), + .init(id: "time", operation: .time), + .init(id: "multiply", operation: .multiply, type: .color, inputs: ["a": .init("sample", port: "color"), "b": .init("tint")]), + .init(id: "output", operation: .output, inputs: ["baseColor": .init("multiply"), "emission": .init("sample", port: "rgb"), "alpha": .init("time")]), + ]) + } + + @Test func bindingsAndParameterSlotsMatchTheGLSLProgram() throws { + let graph = texturedGraph() + let glsl = try graph.compileGLSL() + let wgsl = try graph.compileWGSL() + #expect(wgsl.parameters == glsl.parameters) + #expect(wgsl.textures == glsl.textures) + #expect(wgsl.usesTime == glsl.usesTime) + #expect(Set(wgsl.sourceMap.values.map { "\($0.nodeID)/\($0.port ?? "")" }) == Set(glsl.sourceMap.values.map { "\($0.nodeID)/\($0.port ?? "")" })) + #expect(!wgsl.source.contains("invalid shader identifier")) + + let module = try link(wgsl) + let entry = try #require(module.entryPoints.first) + let layouts = try WESLBackendCompiler.webGPUUniformBuffers(module, entryPoint: entry) + let uniforms = try #require(layouts.first { Int($0.group) == ShaderGraphProgram.resourceSet && Int($0.binding) == ShaderGraphProgram.uniformBinding }) + // One aligned vec4 per parameter plus slot zero for time. + #expect(uniforms.minimumByteSize == UInt64((glsl.parameters.count + 1) * 16)) + // The linker re-tokenizes with spaces, so compare without whitespace. + let compact = module.wgsl.filter { !$0.isWhitespace } + #expect(compact.contains("@binding(\(ShaderGraphProgram.firstTextureBinding))")) + #expect(compact.contains("@binding(\(ShaderGraphProgram.samplerBinding))")) + } + + @Test(arguments: ["flat3d", "directional_shadow_3d", "motion_3d"]) + func graphSplicesIntoTheSurfaceTemplates(_ name: String) throws { + let program = try texturedGraph().compileWGSL() + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let shaders = root.appendingPathComponent("Sources/AdaCorePipelines/Assets/Shaders") + let template = try String(contentsOf: shaders.appendingPathComponent("\(name).wesl"), encoding: .utf8) + let generated = program.inserting(in: template) + var featureSets = [["ADA_SHADER_GRAPH"]] + if name == "flat3d" { featureSets.append(["ADA_SHADER_GRAPH", "ADA_HYBRID_REFLECTION", "ADA_DIFFUSE_SURFACE"]) } + if name != "flat3d" { featureSets.append(["ADA_SHADER_GRAPH", "ADA_MORPH"]) } + for features in featureSets { + let module = try WESLHostCompiler.compile( + source: generated, + packageRoot: shaders, + features: Dictionary(uniqueKeysWithValues: features.map { ($0, true) }), + dependencies: ["ada_engine": root.appendingPathComponent("Sources/AdaRender/Assets/Shaders/WESL")] + ) + #expect(Set(module.entryPoints.map(\.stage)) == ["vertex", "fragment"], "\(name), features: \(features)") + #expect(module.wgsl.contains("ada_graph_evaluate")) + _ = try WESLBackendCompiler.compile(module, to: .msl) + } + } + + @Test(arguments: ["sprite", "sprite_instanced"]) + func graphSplicesIntoTheSpriteTemplates(_ name: String) throws { + let program = try ShaderGraph(target: .sprite, output: "output", nodes: [ + .init(id: "tint", operation: .parameter, type: .color, value: [1, 1, 1, 1], reference: "tint"), + .init(id: "output", operation: .output, inputs: ["color": .init("tint")]), + ]).compileWGSL() + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let assets = root.appendingPathComponent("Sources/AdaSprite/Assets") + let template = try String(contentsOf: assets.appendingPathComponent("\(name).wesl"), encoding: .utf8) + let generated = program.inserting(in: template) + for features in [[], ["ADA_SHADER_GRAPH"]] as [[String]] { + let module = try WESLHostCompiler.compile( + source: features.isEmpty ? template : generated, + packageRoot: assets, + features: Dictionary(uniqueKeysWithValues: features.map { ($0, true) }), + dependencies: ["ada_engine": root.appendingPathComponent("Sources/AdaRender/Assets/Shaders/WESL")] + ) + #expect(Set(module.entryPoints.map(\.stage)) == ["vertex", "fragment"], "\(name), features: \(features)") + #expect(module.wgsl.contains("ada_graph_evaluate") == !features.isEmpty) + _ = try WESLBackendCompiler.compile(module, to: .msl) + } + } + + @Test func resourcesAndDefaultsDoNotChangeCompiledCode() throws { + var graph = texturedGraph() + let before = try graph.compileWGSL() + graph.nodes[1].reference = "other.png" + graph.nodes[3].value = [0, 0, 0, 1] + graph.nodes.reverse() + let after = try graph.compileWGSL() + #expect(before.source == after.source) + #expect(before.parameters != after.parameters) + #expect(before.textures != after.textures) + } + } +#endif diff --git a/Tests/AdaRenderTests/WESLBackendCompilerTests.swift b/Tests/AdaRenderTests/WESLBackendCompilerTests.swift new file mode 100644 index 000000000..6bddc1ae4 --- /dev/null +++ b/Tests/AdaRenderTests/WESLBackendCompilerTests.swift @@ -0,0 +1,87 @@ +#if canImport(AdaWGSLCompiler) && !os(WASI) && !os(Android) +import AdaWGSLCompiler +import Foundation +import Testing + +struct WESLBackendCompilerTests { + @Test(arguments: ["fragment", "compute"]) + func webGPUUniformSizesUseShaderPaddingAndExcludeUnusedBindings(stage: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("main.wesl") + let entry = stage == "fragment" + ? "@fragment fn main() -> @location(0) vec4f { return vec4f(params.tint * params.gain, params.transform[0].x); }" + : "@compute @workgroup_size(1) fn main() { result[0] = params.gain + params.transform[0].x; }" + try """ + struct Params { tint: vec3f, gain: f32, transform: mat3x3f, } + @group(2) @binding(5) var params: Params; + @group(0) @binding(0) var unused: vec4f; + @group(0) @binding(1) var result: array; + \(entry) + """.write(to: file, atomically: true, encoding: .utf8) + let module = try WESLHostCompiler.compile(sourceFile: file, packageRoot: root) + let entryPoint = try #require(module.entryPoints.first) + let layouts = try WESLBackendCompiler.webGPUUniformBuffers(module, entryPoint: entryPoint) + #expect(layouts.count == 1) + let layout = try #require(layouts.first) + #expect(layout.group == 2) + #expect(layout.binding == 5) + // mat3x3f has a 16-byte column stride; the shader block is 64 bytes, not 52. + #expect(layout.minimumByteSize == 64) + } + + @Test(arguments: ["f32", "vec3f", "mat3x3f", "array"]) + func nonStructUniformRootsFollowHelperCalls(type: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("main.wesl") + let access = type == "f32" ? "params" : (type == "vec3f" ? "params.x" : "params[0].x") + try """ + @group(1) @binding(3) var params: \(type); + @group(0) @binding(0) var unused: vec4f; + fn readValue() -> f32 { return \(access); } + fn shade() -> vec4f { return vec4f(readValue()); } + @fragment fn main() -> @location(0) vec4f { return shade(); } + """.write(to: file, atomically: true, encoding: .utf8) + let module = try WESLHostCompiler.compile(sourceFile: file, packageRoot: root) + let entry = try #require(module.entryPoints.first) + let layouts = try WESLBackendCompiler.webGPUUniformBuffers(module, entryPoint: entry) + #expect(layouts.count == 1) + let layout = try #require(layouts.first) + #expect(layout.group == 1) + #expect(layout.binding == 3) + let expected: UInt64 = type == "f32" ? 4 : (type == "vec3f" ? 12 : 48) + #expect(layout.minimumByteSize == expected) + } + + @Test + func nativeStagesRetainEntryPointsAndMetalBindingRemapping() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let source = root.appendingPathComponent("Sources/AdaCorePipelines/Assets/Shaders/quad.wesl") + let module = try WESLHostCompiler.compile( + sourceFile: source, + packageRoot: source.deletingLastPathComponent(), + dependencies: ["ada_engine": root.appendingPathComponent("Sources/AdaRender/Assets/Shaders/WESL")] + ) + let stages = try WESLBackendCompiler.compile(module, to: .msl) + #expect(Set(stages.map(\.stage)) == ["vertex", "fragment"]) + let vertex = try #require(stages.first { $0.stage == "vertex" }) + guard case .render(let reflection) = vertex.reflection else { + Issue.record("Vertex stage must retain render reflection") + return + } + #expect(vertex.sourceEntryPoint == reflection.sourceEntryPoint) + #expect(vertex.entryPoint == reflection.entryPoint) + let metalSource = try #require(String(data: vertex.data, encoding: .utf8)) + #expect(metalSource.contains(reflection.entryPoint)) + let view = try #require(reflection.uniformBindings.first { $0.binding.group == 0 && $0.binding.binding == 2 }) + let slot = try #require(view.flatBindingIndex) + #expect(view.minimumByteSize == 3 * 64) + #expect(slot != view.binding.binding) + #expect(metalSource.contains("[[buffer(\(slot))]]")) + } +} +#endif diff --git a/Tests/AdaRenderTests/WESLLibraryModuleTests.swift b/Tests/AdaRenderTests/WESLLibraryModuleTests.swift new file mode 100644 index 000000000..f0e04ea84 --- /dev/null +++ b/Tests/AdaRenderTests/WESLLibraryModuleTests.swift @@ -0,0 +1,121 @@ +#if canImport(AdaWGSLCompiler) && !os(WASI) && !os(Android) + import AdaWGSLCompiler + import Foundation + import Testing + + /// The shared helper modules in `Assets/Shaders/WESL` replace the GLSL `#include ` headers. + /// Each is linked through a tiny entry shader so a port cannot silently stop compiling. + struct WESLLibraryModuleTests { + struct Case: CustomTestStringConvertible, Sendable { + let name: String + let source: String + var featureSets: [[String]] = [[]] + + var testDescription: String { name } + } + + static let cases: [Case] = [ + Case( + name: "tonemapping", + source: """ + import ada_engine::tonemapping::{adaOutputTransform, adaDitherDisplay}; + @fragment fn main(@builtin(position) p: vec4f) -> @location(0) vec4f { + return vec4f(adaDitherDisplay(adaOutputTransform(p.xyz, p.w), p.xy), 1.0); + } + """ + ), + Case( + name: "color_grading", + source: """ + import ada_engine::color_grading::{adaGradeLinear, adaApplyLut}; + @group(0) @binding(0) var gradingLUT: texture_2d; + @group(0) @binding(1) var gradingSampler: sampler; + @fragment fn main(@builtin(position) p: vec4f) -> @location(0) vec4f { + let graded = adaGradeLinear(p.xyz, p, p, p.xy); + return vec4f(adaApplyLut(gradingLUT, gradingSampler, graded, p.w), 1.0); + } + """ + ), + Case( + name: "shadow_pcf", + source: """ + import ada_engine::shadow_pcf::{adaDirectionalShadowPCF, adaDirectionalShadowPCSS}; + @group(0) @binding(0) var atlasTexture: texture_2d; + @fragment fn main(@builtin(position) p: vec4f) -> @location(0) vec4f { + let a = adaDirectionalShadowPCF(atlasTexture, p.xy, p.zw, p.xy, p.zw, p.x); + let b = adaDirectionalShadowPCSS(atlasTexture, p.xy, p.zw, p.xy, p.zw, p.x, mat4x4f(), p, 2.0); + return vec4f(a, b, 0.0, 1.0); + } + """ + ), + Case( + name: "distance_field", + source: """ + import ada_engine::distance_field::adaDistanceFieldVisibility; + @fragment fn main(@builtin(position) p: vec4f) -> @location(0) vec4f { + return vec4f(adaDistanceFieldVisibility(p.xyz, p.xyz, p.xyz, p.w)); + } + """ + ), + Case( + name: "morph", + source: """ + @if(ADA_MORPH) import ada_engine::morph::{applyMorph, applyMorphPosition, previousMorphedPosition}; + @vertex fn main(@builtin(vertex_index) index: u32, @location(0) position: vec3f) -> @builtin(position) vec4f { + @if(ADA_MORPH) { + let morphed = applyMorph(index, position, position, position); + return vec4f(applyMorphPosition(index, morphed.position) + previousMorphedPosition(index, morphed.normal), 1.0); + } + return vec4f(position, 1.0); + } + """, + featureSets: [[], ["ADA_MORPH"]] + ), + Case( + name: "baked_diffuse", + source: """ + import ada_engine::baked_diffuse::adaBakedDiffuse; + @fragment fn main(@builtin(position) p: vec4f) -> @location(0) vec4f { + return adaBakedDiffuse(p.xyz, p.xyz, p.xy, p, p); + } + """, + featureSets: [[], ["ADA_LIGHTMAP"], ["ADA_IRRADIANCE_GRID"], ["ADA_LIGHTMAP", "ADA_IRRADIANCE_GRID"]] + ), + ] + + private static var shaderPackage: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Sources/AdaRender/Assets/Shaders/WESL") + } + + private func link(_ item: Case, features: [String]) throws -> WESLHostCompiler.Artifact { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("main.wesl") + try item.source.write(to: file, atomically: true, encoding: .utf8) + return try WESLHostCompiler.compile( + sourceFile: file, + packageRoot: root, + features: Dictionary(uniqueKeysWithValues: features.map { ($0, true) }), + dependencies: ["ada_engine": Self.shaderPackage] + ) + } + + @Test(arguments: cases) + func linksAndCompilesToMetal(item: Case) throws { + for features in item.featureSets { + let module = try link(item, features: features) + #expect(module.entryPoints.map(\.name) == ["main"], "features: \(features)") + _ = try WESLBackendCompiler.compile(module, to: .msl) + } + } + + @Test func morphDeclaresNothingWithoutItsFeature() throws { + let morph = try #require(Self.cases.first { $0.name == "morph" }) + #expect(!(try link(morph, features: [])).wgsl.contains("u_MorphDeltas")) + #expect(try link(morph, features: ["ADA_MORPH"]).wgsl.contains("u_MorphDeltas")) + } + } +#endif diff --git a/Tests/AdaRenderTests/WESLShaderModuleTests.swift b/Tests/AdaRenderTests/WESLShaderModuleTests.swift new file mode 100644 index 000000000..5d1d6a5c8 --- /dev/null +++ b/Tests/AdaRenderTests/WESLShaderModuleTests.swift @@ -0,0 +1,35 @@ +#if canImport(WebGPU) && !os(WASI) && !os(Android) +@testable import AdaRender +import Foundation +import Testing + +@MainActor +@Suite("WESL modules on native WebGPU") +struct WESLShaderModuleTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) + func nativeShaderModuleLinksAndReflectsBothBindingLayouts() throws { + unsafe RenderEngine.configurations.preferredBackend = .webgpu + try RenderEngine.setupRenderEngine() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ada wesl \(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("main.wesl") + try "fn tint() -> vec4f { return vec4f(1.0); }".write( + to: root.appendingPathComponent("color.wesl"), atomically: true, encoding: .utf8 + ) + try """ + import package::color::tint; + @if(textured) @group(0) @binding(0) var image: texture_2d; + @fragment fn fragmentMain() -> @location(0) vec4f { + @if(textured) return tint() * textureLoad(image, vec2i(0), 0); + @else return tint(); + } + """.write(to: file, atomically: true, encoding: .utf8) + let plain = try ShaderModule.create(from: file) + let textured = try ShaderModule.createWESL(from: file, features: ["textured": true]) + #expect(plain.getShader(for: .fragment)?.entryPoint == "fragmentMain") + #expect(plain.reflectionData.resources.isEmpty) + #expect(textured.reflectionData.resources["image"]?.binding == 0) + } +} +#endif diff --git a/Tests/AdaRenderTests/WGSLReflectionTests.swift b/Tests/AdaRenderTests/WGSLReflectionTests.swift index 9a330a47d..755b6d38c 100644 --- a/Tests/AdaRenderTests/WGSLReflectionTests.swift +++ b/Tests/AdaRenderTests/WGSLReflectionTests.swift @@ -53,6 +53,8 @@ struct WGSLReflectionTests { @group(0) @binding(1) var terrain: texture_2d_array; @group(0) @binding(2) var sky: texture_cube; @group(0) @binding(3) var volume: texture_3d; + @group(0) @binding(4) var depth: texture_depth_2d; + @group(0) @binding(5) var depthLayers: texture_depth_2d_array; """ let reflection = WGSLReflection.parse(from: source, stage: .fragment) let images = reflection.descriptorSets.first?.sampledImages @@ -60,5 +62,9 @@ struct WGSLReflectionTests { #expect(images?[1]?.textureType == .texture2DArray) #expect(images?[2]?.textureType == .textureCube) #expect(images?[3]?.textureType == .texture3D) + #expect(images?[0]?.isDepth == nil) + #expect(images?[4]?.isDepth == true) + #expect(images?[5]?.isDepth == true) + #expect(images?[5]?.textureType == .texture2DArray) } } diff --git a/Tests/AdaRenderTests/TintShaderCompilerTests.swift b/Tests/AdaRenderTests/WGSLShaderCompilerTests.swift similarity index 87% rename from Tests/AdaRenderTests/TintShaderCompilerTests.swift rename to Tests/AdaRenderTests/WGSLShaderCompilerTests.swift index f914e9b44..b71a10bdb 100644 --- a/Tests/AdaRenderTests/TintShaderCompilerTests.swift +++ b/Tests/AdaRenderTests/WGSLShaderCompilerTests.swift @@ -1,14 +1,13 @@ #if canImport(WebGPU) && !os(WASI) && !os(Android) @testable import AdaRender -import AdaUtils import Foundation import Testing @MainActor -@Suite("Real Tint SPIR-V to WGSL") -struct TintShaderCompilerTests { +@Suite("Real SPIR-V to WGSL") +struct WGSLShaderCompilerTests { @Test( - .enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1"), + .enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1"), arguments: ["flat3d.glsl", "directional_shadow_3d.glsl", "motion_3d.glsl"] ) func compilesFreshReflectedMeshPasses(shader: String) async throws { @@ -27,7 +26,7 @@ struct TintShaderCompilerTests { } } - @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1"), arguments: ["glass.glsl", "gradient.glsl"]) + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1"), arguments: ["glass.glsl", "gradient.glsl"]) func compilesFreshGlassBlur(shader: String) async throws { unsafe RenderEngine.configurations.preferredBackend = .webgpu try RenderEngine.setupRenderEngine() @@ -44,13 +43,12 @@ struct TintShaderCompilerTests { } } - @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_TINT"] == "1")) + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_VALIDATE_WGSL"] == "1")) func compilesFreshShader() async throws { unsafe RenderEngine.configurations.preferredBackend = .webgpu try RenderEngine.setupRenderEngine() - _ = try #require(TintToolchain.executable(), "Tint is required for this dev validation. Run script/ensure_tint.py.") - // Exercise native filesystem paths, including spaces, when invoking Tint. - let root = FileManager.default.temporaryDirectory.appendingPathComponent("tint shader-\(UUID())") + // Exercise native filesystem paths, including spaces, when loading shader source. + let root = FileManager.default.temporaryDirectory.appendingPathComponent("fresh shader-\(UUID())") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let file = root.appendingPathComponent("fresh.glsl") @@ -64,7 +62,14 @@ struct TintShaderCompilerTests { """.write(to: file, atomically: true, encoding: .utf8) let compiler = try ShaderCompiler(from: file) let spirv = try compiler.compileSpirvBin(for: .vertex, ignoreCache: true) - let result = try await WGSLShaderCompiler().compile(spirvData: spirv.data, entryPoint: "sprite_validation", stage: .vertex, defines: []) + let result: DeviceCompiledShader + do { + result = try await WGSLShaderCompiler().compile(spirvData: spirv.data, entryPoint: "sprite_validation", stage: .vertex, defines: []) + } catch { + let compilationError: (any Error)? = error + #expect(compilationError == nil, "Fresh shader compilation failed: \(error)") + return + } #expect(result.language == .wgsl) #expect(result.source.contains("@vertex")) #expect(result.source.contains("fn sprite_validation(")) diff --git a/Tests/AdaShaderGraphBakerTests/ShaderGraphBakerTests.swift b/Tests/AdaShaderGraphBakerTests/ShaderGraphBakerTests.swift index 993504eea..7382a0f8c 100644 --- a/Tests/AdaShaderGraphBakerTests/ShaderGraphBakerTests.swift +++ b/Tests/AdaShaderGraphBakerTests/ShaderGraphBakerTests.swift @@ -32,20 +32,19 @@ struct ShaderGraphBakerTests { func realBakerHandlesInlineOverridesMissingResourcesAndUnicode() throws { let environment = ProcessInfo.processInfo.environment let executable = try #require(environment["ADAENGINE_SHADER_GRAPH_BAKER"]) - let tint = try #require(environment["TINT_EXECUTABLE"]) let root = FileManager.default.temporaryDirectory.appendingPathComponent("Граф со пробелами " + UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let scene = root.appendingPathComponent("Main.ascn") let archive = root.appendingPathComponent("shaders.shadergraphlib") - func run(_ tintPath: String = tint) throws -> (Int32, String) { + func run() throws -> (Int32, String) { let log = root.appendingPathComponent("baker.log") FileManager.default.createFile(atPath: log.path, contents: nil) let handle = try FileHandle(forWritingTo: log) defer { try? handle.close() } let process = Process() process.executableURL = URL(fileURLWithPath: executable) - process.arguments = [root.path, tintPath] + process.arguments = [root.path] process.standardOutput = handle process.standardError = handle try process.run() @@ -82,9 +81,5 @@ struct ShaderGraphBakerTests { #expect(baked.graphs.isEmpty && baked.fragments.count == 2) #expect(baked.texturePaths == ["@res://цвет.png"]) #expect(baked.fragments.allSatisfy { !$0.wgsl.isEmpty && !$0.entryPoint.isEmpty }) - if let failingTint = environment["ADAENGINE_SHADER_GRAPH_FAILING_TINT"] { - let failure = try run(failingTint) - #expect(failure.0 != 0 && failure.1.contains("stderr complete")) - } } } diff --git a/Tests/WebGolden/SAGASmoke/.ada/project.json b/Tests/WebGolden/SAGASmoke/.ada/project.json new file mode 100644 index 000000000..530f9fcd4 --- /dev/null +++ b/Tests/WebGolden/SAGASmoke/.ada/project.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 3, + "project": { + "name": "MeshTerrainWorkshop", + "displayName": "Mesh Terrain Workshop" + }, + "paths": { + "sources": "Sources", + "assets": "Assets", + "resourceRoots": [ + "Assets" + ] + }, + "build": { + "system": "adascript" + }, + "runtime": { + "entry": { + "scene": "Assets/Scenes/Main.ascn" + }, + "moduleName": "MeshTerrainWorkshop", + "plugins": { + "preset": "game3d", + "presetVersion": 1, + "enable": [], + "disable": [] + } + }, + "editor": { + "startupScene": "Assets/Scenes/Main.ascn" + } +} diff --git a/Tests/WebGolden/SAGASmoke/Assets/Scenes/Main.ascn b/Tests/WebGolden/SAGASmoke/Assets/Scenes/Main.ascn new file mode 100644 index 000000000..747f5ac87 --- /dev/null +++ b/Tests/WebGolden/SAGASmoke/Assets/Scenes/Main.ascn @@ -0,0 +1,237 @@ +{ + "format": "ada.scene", + "schemaVersion": 1, + "engineVersion": "1.0.0", + "scene": { + "id": "mesh-terrain-workshop", + "name": "Mesh Terrain Workshop" + }, + "entities": [ + { + "id": "Camera", + "name": "Camera", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + 11, + -19 + ], + "rotation": [ + 0.258819, + 0, + 0, + 0.965926 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaRender.Camera": { + "projection": "perspective", + "isActive": true, + "renderOrder": 0, + "backgroundColor": { + "red": 0.05, + "green": 0.07, + "blue": 0.1, + "alpha": 1 + } + }, + "AdaCorePipelines.Environment3D": {} + } + }, + { + "id": "Sun", + "name": "Sun", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + 4, + -3 + ], + "rotation": [ + 0.2661003137748674, + -0.4155384462484667, + -0.1285411045311107, + 0.8602299734049316 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.DirectionalLightComponent": { + "radiance": [ + 1, + 1, + 1 + ], + "intensity": 3, + "castShadows": true, + "shadowDistance": 30, + "shadowBias": 0.0008, + "shadowSlopeBias": 0.003 + } + } + }, + { + "id": "c1", + "name": "Red cube", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + -4, + 1, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.Mesh3DComponent": { + "mesh": "Cube", + "size": 2, + "color": { + "red": 0.9, + "green": 0.2, + "blue": 0.2, + "alpha": 1 + }, + "metallic": 0, + "roughness": 0.5 + } + } + }, + { + "id": "c2", + "name": "Green sphere", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + 1, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.Mesh3DComponent": { + "mesh": "Sphere", + "size": 2, + "color": { + "red": 0.2, + "green": 0.8, + "blue": 0.3, + "alpha": 1 + }, + "metallic": 0, + "roughness": 0.5 + } + } + }, + { + "id": "c3", + "name": "Blue cube", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 4, + 1, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.Mesh3DComponent": { + "mesh": "Cube", + "size": 2, + "color": { + "red": 0.2, + "green": 0.3, + "blue": 0.9, + "alpha": 1 + }, + "metallic": 0, + "roughness": 0.5 + } + } + }, + { + "id": "floor", + "name": "Floor", + "enabled": true, + "components": { + "AdaTransform.Transform": { + "position": [ + 0, + -15, + 0 + ], + "rotation": [ + 0, + 0, + 0, + 1 + ], + "scale": [ + 1, + 1, + 1 + ] + }, + "AdaScene.Mesh3DComponent": { + "mesh": "Cube", + "size": 30, + "color": { + "red": 0.6, + "green": 0.6, + "blue": 0.6, + "alpha": 1 + }, + "metallic": 0, + "roughness": 0.8 + } + } + } + ], + "editor": { + "selectedEntity": "Terrain", + "expandedEntities": [] + } +} \ No newline at end of file diff --git a/Tests/WebGolden/SAGASmoke/README.md b/Tests/WebGolden/SAGASmoke/README.md new file mode 100644 index 000000000..c20f9054e --- /dev/null +++ b/Tests/WebGolden/SAGASmoke/README.md @@ -0,0 +1,21 @@ +# SAGA browser smoke scene + +A minimal project for the SAGA browser golden-frame check: a perspective camera with `Environment3D`, a shadow-casting +sun, a floor, two cubes and a sphere. It uses only components the web player supports. + +The reference image is +[`GoldenFrames/saga-browser/smoke-160x96.png`](../../AdaEngineTests/GoldenFrames/saga-browser/smoke-160x96.png). + +1. Export the web player (see `Documentation/Tooling.md`, "Browser renderer") and pack this project into it: + `AdaWebPlayerPackager Tests/WebGolden/SAGASmoke `. +2. Run `node script/web_golden_saga.mjs `. It opens `?solo=1`, waits for a settled frame, downsamples + it to 160x96 and compares it with the reference (per-channel tolerance 12, at most 2% bad pixels, mean error at most 4). +3. After an intended rendering change, review the new frame and re-record with `--record`. + +The same helpers are usable from any browser console on the page: `import("./saga-golden.mjs")` exposes +`captureStable`, `checkGolden`, `compareFrames`, `frameToPng` and `frameFromPng`. + +To check a Web export of the Studio [Shader Graph fixture](../../../Editor/Tests/Fixtures/ShaderGraphSmoke), +run `node script/web_golden_saga.mjs /web --studio-export --actual=studio-web.png`. +The check reads the orange sprite pixels from SAGA before and after a viewport resize and +verifies that the resized surface presents another frame. diff --git a/Tests/WebGolden/SAGASmoke/Sources/Main.ada b/Tests/WebGolden/SAGASmoke/Sources/Main.ada new file mode 100644 index 000000000..883bda19b --- /dev/null +++ b/Tests/WebGolden/SAGASmoke/Sources/Main.ada @@ -0,0 +1 @@ +// The workshop uses the native components saved in Main.ascn. diff --git a/Tools/AdaWebPlayer/PlayerAgentBridge.swift b/Tools/AdaWebPlayer/PlayerAgentBridge.swift index 40c569563..e4d3eaff2 100644 --- a/Tools/AdaWebPlayer/PlayerAgentBridge.swift +++ b/Tools/AdaWebPlayer/PlayerAgentBridge.swift @@ -131,7 +131,7 @@ final class PlayerAgentBridge { switch name { case "AdaPhysics.PhysicsBody2DComponent": _ = try JSONDecoder().decode(PhysicsBody2DComponent.self, from: data) case "AdaScene.PointLightComponent": _ = try JSONDecoder().decode(PointLightComponent.self, from: data) - case "AdaRender.Environment3D": _ = try JSONDecoder().decode(Environment3D.self, from: data) + case "AdaRender.Environment3D", "AdaCorePipelines.Environment3D": _ = try JSONDecoder().decode(Environment3D.self, from: data) case "AdaSprite.Sprite": struct SpriteValues: Decodable { let anchor: SpriteAnchor; let imageMode: SpriteImageMode } _ = try JSONDecoder().decode(SpriteValues.self, from: data) diff --git a/Tools/AdaWebPlayer/PlayerSceneComponents.swift b/Tools/AdaWebPlayer/PlayerSceneComponents.swift index 56d05f4d8..04ef77aa4 100644 --- a/Tools/AdaWebPlayer/PlayerSceneComponents.swift +++ b/Tools/AdaWebPlayer/PlayerSceneComponents.swift @@ -60,7 +60,10 @@ import Foundation components.append(DirectionalLightComponent(radiance: color.isEmpty ? .one : Vector3(Float(color[0]), Float(color[1]), Float(color[2])), intensity: Float(payload["intensity"].number ?? 1), castShadows: payload["castShadows"] != .bool(false))) case "AdaScene.PointLightComponent": components.append(try decode(PointLightComponent.self, payload)) - case "AdaRender.Environment3D": components.append(try decode(Environment3D.self, payload)) + // The editor writes the native module name for built-in pipeline components. + case "AdaRender.Environment3D", "AdaCorePipelines.Environment3D": components.append(try decode(Environment3D.self, payload)) + case "AdaScene.ScriptableComponents": components.append(try decode(ScriptableComponents.self, payload)) + case "AdaUI.CompanionPanel": components.append(try decode(CompanionPanel.self, payload)) case "AdaPhysics.PhysicsBody2DComponent": components.append(try decode(PhysicsBody2DComponent.self, payload)) case "AdaAudio.AudioSource": guard let source = payload["source"].string else { throw invalid("Audio source is required") } diff --git a/Tools/Android/README.md b/Tools/Android/README.md index d60fa7c17..138c9854e 100644 --- a/Tools/Android/README.md +++ b/Tools/Android/README.md @@ -61,6 +61,11 @@ OpenGL/GLES backends. It strips debug data only from packaged copies. ## Build and run +To try the native SAGA Vulkan path, set `ADAENGINE_SAGA_NATIVE=1` and place +the SAGA checkout beside AdaEngine or set `ADAENGINE_SAGA_PACKAGE_PATH` to its +absolute path. In this configuration the command-line APK builder uses SAGA and +the system Vulkan loader. The Swift and Android SDK requirements still apply. + From the AdaEngine repository root: ```sh diff --git a/Tools/Android/android.py b/Tools/Android/android.py index c0a57391a..3641bc69a 100644 --- a/Tools/Android/android.py +++ b/Tools/Android/android.py @@ -46,9 +46,17 @@ def tool(path): def build_env(): env = os.environ.copy() - env.update(ADAENGINE_ANDROID="1", SWAN_RUNTIME_ONLY="1") - env.pop("ADAENGINE_DISABLE_SWAN", None) + env["ADAENGINE_ANDROID"] = "1" env.pop("ADAENGINE_HEADLESS", None) + if env.get("ADAENGINE_SAGA_NATIVE") == "1": + checkout = Path(env.get("ADAENGINE_SAGA_PACKAGE_PATH") or ROOT.parent / "SAGA").expanduser().resolve() + if not (checkout / "Package.swift").is_file(): + raise AndroidError(f"SAGA checkout not found at {checkout}. Set ADAENGINE_SAGA_PACKAGE_PATH.") + env["ADAENGINE_SAGA_PACKAGE_PATH"] = str(checkout) + env["ADAENGINE_DISABLE_SWAN"] = "1" + else: + env.pop("ADAENGINE_DISABLE_SWAN", None) + env["SWAN_RUNTIME_ONLY"] = "1" if not env.get("JAVA_HOME"): candidates = [Path("/Applications/Android Studio.app/Contents/jbr/Contents/Home")] jdk = next((path for path in candidates if (path/"bin/java").exists()), None) @@ -235,7 +243,8 @@ def select_swift_sdks(args,bundle): def build(args): validate_options(args) env=build_env() - validate_webgpu(args,env) + if env.get("ADAENGINE_SAGA_NATIVE") != "1": + validate_webgpu(args,env) sdk=sdk_path(args) tools=sdk/"build-tools"/args.build_tools swift_roots,bundle=swift_sdk_root(args) @@ -363,7 +372,10 @@ def main(): else: validate_options(args) env=build_env() - print("Dawn WebGPU:",validate_webgpu(args,env)) + if env.get("ADAENGINE_SAGA_NATIVE") == "1": + print("SAGA Vulkan:",env["ADAENGINE_SAGA_PACKAGE_PATH"]) + else: + print("Dawn WebGPU:",validate_webgpu(args,env)) print(run(swift_command(args)+["--version"],env=env,capture=True).strip()) print("Swift SDK:",swift_sdk_root(args)[1]) print("NDK:",ndk_host(ndk_path(args))) diff --git a/Tools/Android/tests/test_android.py b/Tools/Android/tests/test_android.py index 77fc0b27a..4dc24f727 100644 --- a/Tools/Android/tests/test_android.py +++ b/Tools/Android/tests/test_android.py @@ -172,6 +172,17 @@ def test_callback_without_a_java_host_is_rejected(self): unittest.main() class AndroidWebGPUSetupTests(unittest.TestCase): + def test_saga_build_uses_the_checkout_without_requiring_dawn(self): + with tempfile.TemporaryDirectory() as temporary: + checkout=Path(temporary)/"SAGA checkout" + checkout.mkdir() + (checkout/"Package.swift").write_text("// fixture") + from unittest.mock import patch + with patch.dict("os.environ",{"ADAENGINE_SAGA_NATIVE":"1","ADAENGINE_SAGA_PACKAGE_PATH":str(checkout)},clear=True): + env=android.build_env() + self.assertEqual(env.get("ADAENGINE_DISABLE_SWAN"),"1","SAGA builds must disable Swan") + self.assertEqual(env["ADAENGINE_SAGA_PACKAGE_PATH"],str(checkout.resolve())) + def test_requires_explicit_android_dawn_setup(self): args=android.parser().parse_args(["build"]) with self.assertRaisesRegex(android.AndroidError,"ADAENGINE_SWAN_PACKAGE_PATH"): diff --git a/Tools/Windows/build_worker.py b/Tools/Windows/build_worker.py index 724e23191..0a893acbc 100644 --- a/Tools/Windows/build_worker.py +++ b/Tools/Windows/build_worker.py @@ -52,7 +52,7 @@ def main(): parser.add_argument("--mode", choices=["web", "android"], required=True) for name in ["engine", "package", "output", "product", "swift-sdk"]: parser.add_argument("--" + name, required=True) - for name in ["swift", "gravity", "scratch", "tint", "application-id", "label", "debug-keystore", "swan"]: + for name in ["swift", "gravity", "scratch", "saga", "application-id", "label", "debug-keystore", "swan"]: parser.add_argument("--" + name) parser.add_argument("--configuration", choices=["debug", "release"], default="release") parser.add_argument("--abi", choices=["arm64-v8a", "x86_64", "all"], default="arm64-v8a") @@ -76,14 +76,8 @@ def main(): os.chdir(package) if args.mode == "web": env.update(ADAENGINE_WEB_EXPORT="1", BUILD_WASM="1") - env["ADAENGINE_TINT_CACHE"] = str(HOME / "tint") - tint = args.tint - if tint and tint.lower().endswith(".exe"): - tint = None - if not tint: - result = subprocess.run([sys.executable, str(engine / "script/ensure_tint.py")], env=env, check=True, text=True, stdout=subprocess.PIPE) - tint = result.stdout.strip().splitlines()[-1] - env["TINT_EXECUTABLE"] = tint + if args.saga: + env["ADAENGINE_SAGA_PACKAGE_PATH"] = str(Path(args.saga).resolve()) command = [swift, "package", "--disable-sandbox", "--scratch-path", scratch / "host", "--allow-writing-to-package-directory", "--allow-network-connections", "all", "export-web", "--product", args.product, "--output", args.output, "--scratch-path", scratch / "wasm", @@ -98,12 +92,14 @@ def main(): SWIFT_ANDROID_SDKS_PATH=str(Path.home() / ".swiftpm/swift-sdks"), ANDROID_HOME=str(HOME / "android-sdk"), ANDROID_NDK_HOME=str(HOME / "android-ndk-r28c"), JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64") - swan = Path(args.swan).resolve() if args.swan else HOME / "swan-android" - # A configured Swan must actually contain an Android artifact. The managed - # copy is prepared against the exact Dawn/binding revision by the setup tool. - if not (swan / "Dawn/dist/android.artifactbundle/info.json").is_file(): - swan = HOME / "swan-android" - env.update(ADAENGINE_SWAN_PACKAGE_PATH=str(swan), SWAN_LOCAL_DAWN="Dawn/dist/android.artifactbundle") + if args.saga: + env.update(ADAENGINE_SAGA_NATIVE="1", ADAENGINE_SAGA_PACKAGE_PATH=str(Path(args.saga).resolve())) + else: + swan = Path(args.swan).resolve() if args.swan else HOME / "swan-android" + # The managed fallback matches the Dawn revision used by its Swan bindings. + if not (swan / "Dawn/dist/android.artifactbundle/info.json").is_file(): + swan = HOME / "swan-android" + env.update(ADAENGINE_SWAN_PACKAGE_PATH=str(swan), SWAN_LOCAL_DAWN="Dawn/dist/android.artifactbundle") command = [sys.executable, engine / "Tools/Android/android.py", "build", "--package", package, "--product", args.product, "--application-id", args.application_id, "--label", args.label or args.product, "--abi", args.abi, diff --git a/script/ada_tools/environment.py b/script/ada_tools/environment.py index 60d72ab2a..686737e3c 100644 --- a/script/ada_tools/environment.py +++ b/script/ada_tools/environment.py @@ -3,7 +3,6 @@ import os from pathlib import Path import re -import subprocess import sys import tempfile @@ -289,22 +288,18 @@ def native_dependencies(ctx): if not ctx.dry: import shutil shutil.copy2(ctx.cache / "gravity/bin/gravity", Path(gravity) / "gravity") - # Preserve and validate explicit Tint overrides rather than silently replacing them. - tint = ctx.env.get("TINT_EXECUTABLE") - if tint: - if not ctx.dry: - from script.ensure_tint import verify - with tempfile.TemporaryDirectory(prefix="ada-tint-") as work: - verify(Path(tint), Path(work)) - else: - ctx.set("TINT_EXECUTABLE", ctx.helper("ensure_tint.py", "--jobs", ctx.jobs)) def wsl(ctx, arguments): if not ctx.which("wsl.exe") and not ctx.dry: raise ToolError("Install WSL 2 with Ubuntu 24.04: wsl --install -d Ubuntu-24.04; then run prepare wasm") root = ctx.run(["wsl.exe", "--exec", "wslpath", "-a", "-u", ctx.root], capture=True) or "/mnt//AdaEngine" - return ctx.run(["wsl.exe", "--cd", root, "--exec", "python3", root + "/ada_tool.py", *arguments]) + environment = [] + if saga := ctx.env.get("ADAENGINE_SAGA_PACKAGE_PATH"): + if not saga.startswith("/"): + saga = ctx.run(["wsl.exe", "--exec", "wslpath", "-a", "-u", saga], capture=True) or saga + environment = ["env", f"ADAENGINE_SAGA_PACKAGE_PATH={saga}"] + return ctx.run(["wsl.exe", "--cd", root, "--exec", *environment, "python3", root + "/ada_tool.py", *arguments]) def prepare(ctx, args): @@ -318,7 +313,7 @@ def prepare(ctx, args): prepare_swift(ctx, args.skip_system) if "studio" in selected: native_dependencies(ctx) - elif "tint" in selected: + if "tint" in selected: ctx.set("TINT_EXECUTABLE", ctx.helper("ensure_tint.py", "--jobs", ctx.jobs)) if "wasm" in selected: if ctx.host == "windows": @@ -334,7 +329,6 @@ def prepare(ctx, args): existing = ctx.run([ctx.swift, "sdk", "list"], capture=True) if sdk["sdk"] not in existing.splitlines(): ctx.run([ctx.swift, "sdk", "install", sdk["url"], "--checksum", sdk["sha256"]]) - ctx.set("TINT_EXECUTABLE", ctx.helper("ensure_tint.py", "--jobs", ctx.jobs)) if "playground" in selected: prepare_node(ctx) pnpm(ctx, "install", "--frozen-lockfile") @@ -365,21 +359,12 @@ def add(name, ok, detail, fix="", required=True): add("Swift", actual == expected, f"{actual or 'missing'}; expected {expected}", "python ada_tool.py prepare swift") for tool in ("git", "cmake", "ninja"): add(tool, ctx.which(tool), ctx.which(tool) or "missing", "python ada_tool.py prepare studio") - # ensure_tint --check creates directories; doctor instead hashes and smokes in a temporary directory. - from script.ensure_tint import PIN as tint_pin, verify - cache = Path(ctx.env.get("ADAENGINE_TINT_CACHE", ctx.root / ".build-tools/tint")) - tint = Path(ctx.env.get("TINT_EXECUTABLE", cache / "bin" / f"{ctx.arch}-{ctx.host}" / ("tint.exe" if ctx.host == "windows" else "tint"))) - try: - from .context import sha256 - if not ctx.env.get("TINT_EXECUTABLE"): - manifest = json.loads(tint.with_name("toolchain.json").read_text()) - if manifest.get("revision") != tint_pin["revision"] or manifest.get("sha256") != sha256(tint): - raise ToolError("Tint revision/hash mismatch") - with tempfile.TemporaryDirectory(prefix="ada-doctor-tint-") as work: - verify(tint, Path(work)) - add("Tint", True, str(tint)) - except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: - add("Tint", False, str(error), "python ada_tool.py prepare tint") + saga_override = ctx.env.get("ADAENGINE_SAGA_PACKAGE_PATH") + if saga_override or (studio and ctx.host != "macos"): + saga = Path(saga_override or ctx.root.parent / "SAGA") + add("SAGA", (saga / "Package.swift").is_file(), str(saga), "Set ADAENGINE_SAGA_PACKAGE_PATH to the local SAGA checkout") + else: + add("SAGA", True, "Revision managed by SwiftPM") if studio: if ctx.host == "windows": add("Windows architecture", ctx.arch == "x86_64", ctx.arch, "Studio packaging currently requires Windows x64") diff --git a/script/ada_tools/studio.py b/script/ada_tools/studio.py index 881ce166b..fff2d73db 100644 --- a/script/ada_tools/studio.py +++ b/script/ada_tools/studio.py @@ -82,13 +82,12 @@ def distribution(ctx, args, binary, work): if ctx.host == "macos": return binary compiler = ctx.env["ADAENGINE_GRAVITY_PACKAGE_PATH"] - tint = ctx.env["TINT_EXECUTABLE"] if ctx.host == "windows": path = ctx.helper("package_studio_windows.py", "--bin", binary, "--compiler-root", compiler, - "--tint", tint, "--output", work, "--version", "local-" + configuration(args), "--stage-only", + "--output", work, "--version", "local-" + configuration(args), "--stage-only", fallback=work / ("AdaStudio-local-" + configuration(args) + "-windows-x86_64")) else: - path = ctx.helper("package_studio_linux.py", "--binary-directory", binary, "--compiler", compiler, "--tint", tint, + path = ctx.helper("package_studio_linux.py", "--binary-directory", binary, "--compiler", compiler, "--swan", ctx.env["ADAENGINE_SWAN_PACKAGE_PATH"], "--dawn", ctx.env["SWAN_LOCAL_DAWN"], "--output", work, "--version", "local-" + configuration(args), fallback=work / "AdaStudio-local-linux") return Path(path) diff --git a/script/build_studio_windows.ps1 b/script/build_studio_windows.ps1 index ccccf5d72..1160e8c58 100644 --- a/script/build_studio_windows.ps1 +++ b/script/build_studio_windows.ps1 @@ -60,9 +60,6 @@ if (-not (Get-Command ninja -ErrorAction SilentlyContinue)) { } } -$studioTint = & python (Join-Path $studioRoot 'script/ensure_tint.py') --jobs $Jobs -if ($LASTEXITCODE -ne 0) { throw 'Tint setup failed. Check Python, CMake, Ninja and the C++ toolchain.' } -$env:TINT_EXECUTABLE = ($studioTint | Select-Object -Last 1).Trim() # Debug also prepares the package tests. Release ships the ordinary executable # without enabling testable imports in production modules. diff --git a/script/export_studio_web.py b/script/export_studio_web.py index 7bbb2152e..0bb8dfa65 100644 --- a/script/export_studio_web.py +++ b/script/export_studio_web.py @@ -11,7 +11,6 @@ import re import shutil import subprocess -import sys ROOT = Path(__file__).resolve().parents[1] @@ -70,10 +69,6 @@ def main(): if compiler: # The export plugin invokes `swift` itself; keep it on the requested toolchain. env['PATH'] = str(Path(compiler).resolve().parent) + os.pathsep + env.get('PATH', '') - if not env.get('TINT_EXECUTABLE'): - tint = subprocess.run([sys.executable, str(ROOT / 'script/ensure_tint.py')], - stdout=subprocess.PIPE, text=True, check=True, env=env) - env['TINT_EXECUTABLE'] = tint.stdout.strip().splitlines()[-1] if 'ADAENGINE_YAMS_PACKAGE_PATH' not in env: from ensure_wasi_yams import prepare as prepare_yams # Keep the package identity "yams" so transitive dependencies use the same local override. diff --git a/script/install_studio_windows.ps1 b/script/install_studio_windows.ps1 index ed808590a..a47ea27c4 100644 --- a/script/install_studio_windows.ps1 +++ b/script/install_studio_windows.ps1 @@ -72,7 +72,7 @@ try { $studioBin = (& $studioBuilder -Configuration release -ScratchPath $ScratchPath -Jobs $Jobs | Select-Object -Last 1).Trim() if ($LASTEXITCODE -ne 0) { throw 'Studio build failed.' } $SourcePath = (& python (Join-Path $PSScriptRoot 'package_studio_windows.py') ` - --bin $studioBin --compiler-root $env:ADAENGINE_GRAVITY_PACKAGE_PATH --tint $env:TINT_EXECUTABLE ` + --bin $studioBin --compiler-root $env:ADAENGINE_GRAVITY_PACKAGE_PATH ` --output $studioBuild --version local-install --stage-only | Select-Object -Last 1).Trim() if ($LASTEXITCODE -ne 0) { throw 'Studio packaging failed.' } } diff --git a/script/package_studio_linux.py b/script/package_studio_linux.py index 7f80a3b02..d1ce71cb3 100644 --- a/script/package_studio_linux.py +++ b/script/package_studio_linux.py @@ -16,7 +16,7 @@ ROOT = Path(__file__).resolve().parents[1] -def package(binary_directory, compiler, tint, swan, dawn, output, version): +def package(binary_directory, compiler, swan, dawn, output, version): if not version or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_" for c in version): raise ValueError("Version must be a filename component") metadata = json.loads(subprocess.check_output(["swift", "-print-target-info"], text=True)) @@ -41,7 +41,6 @@ def package(binary_directory, compiler, tint, swan, dawn, output, version): for required in ("AdaEditor_AdaEditor.resources/Assets", "AdaEngine_AdaRender.resources"): if not (directory / required).is_dir(): raise RuntimeError("Missing SwiftPM resources: " + required) - shutil.copy2(tint, directory / "AdaEngine_AdaRender.resources/tint") runtime = directory / "lib" runtime.mkdir() lore_sdk = ensure_lore() @@ -70,7 +69,6 @@ def package(binary_directory, compiler, tint, swan, dawn, output, version): export ADAENGINE_SWAN_PACKAGE_PATH="$root/BuildSDK/Swan" export SWAN_LOCAL_DAWN=DawnLinux.artifactbundle export ADAENGINE_SKIP_SWIFTLINT=1 -export TINT_EXECUTABLE="$root/AdaEngine_AdaRender.resources/tint" exec "$root/AdaEditor" "$@" ''', encoding="utf-8") (directory / "ada-studio").chmod(0o755) @@ -89,7 +87,7 @@ def package(binary_directory, compiler, tint, swan, dawn, output, version): "Ada Studio Standalone for Linux\n\nRun ./ada-studio from this folder.\n" "Requires an X11 desktop (or XWayland), Vulkan drivers, libX11, libpulse and Zenity.\n" "Built on the current host: use a distribution with compatible glibc/libstdc++.\n" - "Swift runtime, resources, Tint, AdaScript compiler and source BuildSDK are bundled.\n" + "Swift runtime, resources, AdaScript compiler and source BuildSDK are bundled.\n" "Building Swift games also requires Swift 6.3.2, clang, pkg-config, libx11-dev and libasound2-dev.\n" "Web/Android export, remote AdaPlayer, local agents and debugger parity are not validated on Linux.\n", encoding="utf-8", @@ -97,7 +95,7 @@ def package(binary_directory, compiler, tint, swan, dawn, output, version): # Resolve every executable with the bundled runtime, detecting missing host dependencies. environment = os.environ | {"LD_LIBRARY_PATH": str(runtime)} for executable in (directory / "AdaEditor", directory / "gravity-lsp", directory / "AdaPackageTool", - directory / "BuildSDK/AdaScript/gravity", directory / "AdaEngine_AdaRender.resources/tint", runtime / "liblore.so"): + directory / "BuildSDK/AdaScript/gravity", runtime / "liblore.so"): result = subprocess.run(["ldd", str(executable)], env=environment, text=True, capture_output=True) if "not found" in result.stdout or result.returncode not in (0, 1): raise RuntimeError("Unresolved Linux dependencies: " + result.stdout + result.stderr) @@ -116,12 +114,12 @@ def package(binary_directory, compiler, tint, swan, dawn, output, version): def main(): parser = argparse.ArgumentParser(description=__doc__) - for option in ("binary-directory", "compiler", "tint", "swan", "output"): + for option in ("binary-directory", "compiler", "swan", "output"): parser.add_argument("--" + option, type=Path, required=True) parser.add_argument("--dawn", default="DawnLinux.artifactbundle") parser.add_argument("--version", required=True) args = parser.parse_args() - print(package(args.binary_directory.resolve(), args.compiler.resolve(), args.tint.resolve(), args.swan.resolve(), + print(package(args.binary_directory.resolve(), args.compiler.resolve(), args.swan.resolve(), args.dawn, args.output.resolve(), args.version)) diff --git a/script/package_studio_windows.py b/script/package_studio_windows.py index 6724d109c..3576c9151 100644 --- a/script/package_studio_windows.py +++ b/script/package_studio_windows.py @@ -72,12 +72,9 @@ def smoke(directory): # Exercise the shipped executable with no Swift/toolchain/repository on PATH. # EOF is the ordinary MCP bridge shutdown path and needs neither GUI nor server. with tempfile.TemporaryDirectory(prefix="ada-release-smoke-") as work: - shader = Path(work) / "smoke.wgsl" - shader.write_text("@vertex fn main() -> @builtin(position) vec4 { return vec4(0.0); }", encoding="utf-8") for command in [[directory / "AdaEditor.exe", "--editor-mcp-stdio"], [directory / "adastudio.exe", "--help"], - [directory / "BuildSDK/AdaScript/gravity.exe", "--help"], - [directory / "AdaEngine_AdaRender.resources/tint.exe", shader, "--format", "wgsl"]]: + [directory / "BuildSDK/AdaScript/gravity.exe", "--help"]]: result = subprocess.run([str(value) for value in command], input=b"", cwd=work, env=clean_environment(), capture_output=True, timeout=45) if result.returncode != 0: @@ -88,7 +85,7 @@ def smoke(directory): raise RuntimeError(f"Packaged CLI version failed: {cli.stderr.decode(errors='replace')}") -def package(binary_directory, compiler, tint, output, version, smoke_test=True, *, stage_only=False): +def package(binary_directory, compiler, output, version, smoke_test=True, *, stage_only=False): if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,79}", version): raise ValueError("Version must be a filename-safe release tag (1–80 characters).") metadata = json.loads(run("swiftc", "-print-target-info")) @@ -114,7 +111,6 @@ def package(binary_directory, compiler, tint, output, version, smoke_test=True, for required in ["AdaEditor_AdaEditor.resources/Assets", "AdaEngine_AdaRender.resources"]: if not (directory / required).is_dir(): raise RuntimeError(f"Required SwiftPM resources are missing: {required}") - shutil.copy2(tint, directory / "AdaEngine_AdaRender.resources/tint.exe") spec = importlib.util.spec_from_file_location("stage_build_sdk", ROOT / "Editor/scripts/stage-build-sdk.py") sdk = importlib.util.module_from_spec(spec) spec.loader.exec_module(sdk) @@ -146,10 +142,9 @@ def package(binary_directory, compiler, tint, output, version, smoke_test=True, 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", - directory / "AdaEngine_AdaRender.resources/tint.exe"] + binaries = list(directory.glob("*.dll")) + [directory / "AdaEditor.exe", directory / "adastudio.exe", directory / "BuildSDK/AdaScript/gravity.exe"] bundle_dependencies(directory, binaries, search) - for helper in [directory / "BuildSDK/AdaScript/gravity.exe", directory / "AdaEngine_AdaRender.resources/tint.exe"]: + for helper in [directory / "BuildSDK/AdaScript/gravity.exe"]: bundle_dependencies(helper.parent, [helper], [directory] + search) notices = directory / "ThirdPartyNotices" @@ -157,9 +152,7 @@ def package(binary_directory, compiler, tint, output, version, smoke_test=True, lore_notices.mkdir(parents=True, exist_ok=True) for notice in ["LICENSE.txt", "THIRD-PARTY-NOTICES.txt"]: shutil.copy2(lore_sdk / notice, lore_notices / notice) - tint_pin = json.loads((ROOT / "Tools/Tint/toolchain.json").read_text()) - dependencies = list((binary_directory.parent.parent / "checkouts").iterdir()) + [compiler, - tint.parent.parent.parent / tint_pin["revision"] / "source"] + dependencies = list((binary_directory.parent.parent / "checkouts").iterdir()) + [compiler] dependencies += list((ROOT / ".build-tools").glob("adamcp-*")) for dependency in dependencies: if dependency.is_dir(): @@ -178,7 +171,6 @@ def package(binary_directory, compiler, tint, output, version, smoke_test=True, shutil.copy2(ROOT / "LICENSE", directory) for installer in ["install_studio_windows.ps1", "install_studio_windows.cmd"]: shutil.copy2(ROOT / "script" / installer, directory) - shutil.copy2(ROOT / "Tools/Tint/toolchain.json", directory / "tint-toolchain.json") (directory / "release.json").write_text(json.dumps({"version": version, "distribution": "standalone", "commit": run("git", "-C", ROOT, "rev-parse", "HEAD"), "swift": metadata["compilerVersion"], "target": metadata["target"]["triple"]}, indent=2) + "\n") (directory / "README.txt").write_text( @@ -220,12 +212,11 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--bin", required=True, type=Path) parser.add_argument("--compiler-root", required=True, type=Path) - parser.add_argument("--tint", required=True, type=Path) parser.add_argument("--output", type=Path, default=ROOT / "dist") parser.add_argument("--version", required=True) parser.add_argument("--stage-only", action="store_true", help="Prepare and verify a complete standalone directory without creating a ZIP") args = parser.parse_args() - package(args.bin.resolve(), args.compiler_root.resolve(), args.tint.resolve(), args.output.resolve(), args.version, stage_only=args.stage_only) + package(args.bin.resolve(), args.compiler_root.resolve(), args.output.resolve(), args.version, stage_only=args.stage_only) if __name__ == "__main__": diff --git a/script/run_studio_linux.sh b/script/run_studio_linux.sh index 92c02e8eb..8637ab008 100644 --- a/script/run_studio_linux.sh +++ b/script/run_studio_linux.sh @@ -8,7 +8,6 @@ configuration="${ADA_STUDIO_CONFIGURATION:-release}" version="${ADA_STUDIO_VERSION:-development-$(date +%Y%m%d-%H%M%S)}" : "${ADAENGINE_SWAN_PACKAGE_PATH:?Prepare Linux Swan first; see Documentation/Linux.md}" : "${SWAN_LOCAL_DAWN:?Set SWAN_LOCAL_DAWN to the Linux Dawn artifact relative to Swan}" -: "${TINT_EXECUTABLE:?Set TINT_EXECUTABLE to the pinned Linux Tint executable}" export ADAENGINE_GRAVITY_PACKAGE_PATH="${ADAENGINE_GRAVITY_PACKAGE_PATH:-$root/Sources/gravity-lang}" export ADAENGINE_PACKAGE_PATH="$root" export ADAENGINE_SKIP_SWIFTLINT=1 @@ -27,7 +26,7 @@ swift build --package-path "$root/Editor" --scratch-path "$scratch" -c "$configu binary="$(swift build --package-path "$root/Editor" --scratch-path "$scratch" -c "$configuration" --show-bin-path)" compiler="$ADAENGINE_GRAVITY_PACKAGE_PATH" distribution="$(python3 "$root/script/package_studio_linux.py" \ - --binary-directory "$binary" --compiler "$compiler" --tint "$TINT_EXECUTABLE" \ + --binary-directory "$binary" --compiler "$compiler" \ --swan "$ADAENGINE_SWAN_PACKAGE_PATH" --dawn "$SWAN_LOCAL_DAWN" \ --output "$cache/distributions" --version "$version" | tail -n 1)" exec "$distribution/ada-studio" "$@" diff --git a/script/run_studio_windows.ps1 b/script/run_studio_windows.ps1 index 9fab4a9cd..e65780d64 100644 --- a/script/run_studio_windows.ps1 +++ b/script/run_studio_windows.ps1 @@ -10,7 +10,7 @@ if ($LASTEXITCODE -ne 0) { throw 'Studio build failed.' } $studioRoot = Split-Path -Parent $PSScriptRoot $studioVersion = 'standalone-local-' + (Get-Date -Format 'yyyyMMdd-HHmmssfff') $studioDirectory = (& python (Join-Path $PSScriptRoot 'package_studio_windows.py') ` - --bin $studioBin --compiler-root $env:ADAENGINE_GRAVITY_PACKAGE_PATH --tint $env:TINT_EXECUTABLE ` + --bin $studioBin --compiler-root $env:ADAENGINE_GRAVITY_PACKAGE_PATH ` --output (Join-Path $studioRoot '.build-tools/standalone-studio') --version $studioVersion --stage-only | Select-Object -Last 1).Trim() if ($LASTEXITCODE -ne 0) { throw 'Standalone Studio staging failed.' } Start-Process -FilePath (Join-Path $studioDirectory 'AdaEditor.exe') -WorkingDirectory $studioDirectory -WindowStyle Normal diff --git a/script/shader_graph_apple_validation.sh b/script/shader_graph_apple_validation.sh index 620b0fb14..3b8d5cf24 100644 --- a/script/shader_graph_apple_validation.sh +++ b/script/shader_graph_apple_validation.sh @@ -3,29 +3,50 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" cd "$root" -check="${1:?Expected cpu-baker, metal-native or ipad-simulator}" +check="${1:?Expected cpu-baker, saga-default, metal-native, ipad-simulator or studio-web}" evidence="${2:?Expected an evidence directory}" mkdir -p "$evidence" exec > >(tee "$evidence/validation.log") 2>&1 xcodebuild -version swift --version +# The Xcode wrappers run the build tool from the exact SwiftTerm revision in Editor/Package.swift. +if [[ "${GITHUB_ACTIONS:-}" == true && ( "$check" == ipad-simulator || "$check" == studio-web ) ]]; then + python3 - <<'PY' +import json +from pathlib import Path + +path = Path.home() / "Library/org.swift.swiftpm/security/plugins.json" +path.parent.mkdir(parents=True, exist_ok=True) +approvals = json.loads(path.read_text()) if path.exists() else [] +approval = { + "packageIdentity": "swiftterm", + "targetName": "SwiftTermBuildInfoPlugin", + "fingerprint": "caa1bdd3f1396c5b0ed66121f299d1c8b42bc94c", +} +if approval not in approvals: + approvals.append(approval) + path.write_text(json.dumps(approvals) + "\n") +PY +fi + case "$check" in cpu-baker) - export ADAENGINE_HEADLESS=1 ADAENGINE_SKIP_WEBGPU_PLUGINS=1 - export TINT_EXECUTABLE - TINT_EXECUTABLE="$(python3 script/ensure_tint.py)" - python3 script/ensure_tint.py --check + export ADAENGINE_HEADLESS=1 ADAENGINE_SKIP_WEBGPU_PLUGINS=1 ADAENGINE_WEB_EXPORT=1 package="$(python3 script/shader_graph_validation_package.py "$RUNNER_TEMP/cpu-validation-package")" scratch="$RUNNER_TEMP/cpu-validation-build" - swift build --package-path "$package" --scratch-path "$scratch" --jobs 3 --product AdaShaderGraphBaker - swift build --package-path "$package" --scratch-path "$scratch" --jobs 3 --product FailingShaderCompiler + swift build --package-path "$package" --scratch-path "$scratch" --jobs 3 --product ShaderGraphValidationBaker bin="$(swift build --package-path "$package" --scratch-path "$scratch" --show-bin-path)" - ADAENGINE_SHADER_GRAPH_BAKER="$bin/AdaShaderGraphBaker" \ - ADAENGINE_SHADER_GRAPH_FAILING_TINT="$bin/FailingShaderCompiler" \ + ADAENGINE_SHADER_GRAPH_BAKER="$bin/ShaderGraphValidationBaker" \ swift test --package-path "$package" --scratch-path "$scratch" --jobs 3 --no-parallel ;; + saga-default) + swift build --scratch-path "$RUNNER_TEMP/saga-default-build" --jobs 3 --target AdaEngine + swift build --scratch-path "$RUNNER_TEMP/saga-tvos-build" --jobs 3 --target AdaEngine \ + --triple arm64-apple-tvos18.0 --sdk "$(xcrun --sdk appletvos --show-sdk-path)" + ;; metal-native) + export ADAENGINE_SAGA_METAL=0 make -C Sources/gravity-lang -j 3 gravity package="$(python3 script/webgpu_validation_package.py "$RUNNER_TEMP/metal-validation-package")" # Preserve the failing gate's status while still exercising native export. @@ -85,18 +106,58 @@ PY if ! xcrun simctl list devices booted | grep -Fq "$device"; then xcrun simctl boot "$device"; fi xcrun simctl bootstatus "$device" -b xcodegen generate --spec Editor/project.yml - xcodebuild -project Editor/AdaEditor.xcodeproj -scheme AdaEditor-iPadOS \ - -configuration Debug -destination "id=$device" -derivedDataPath "$RUNNER_TEMP/ipad-build" \ - CODE_SIGNING_ALLOWED=NO build + build_args=(-project Editor/AdaEditor.xcodeproj -scheme AdaEditor-iPadOS + -configuration Debug -destination "id=$device" -derivedDataPath "$RUNNER_TEMP/ipad-build" + CODE_SIGNING_ALLOWED=NO build) + build_status=0 + xcodebuild "${build_args[@]}" || build_status=$? + # Xcode can abort while inserting package references during a cold resolution. + # Keep the fetched dependencies and retry the same build once. + if [[ "$build_status" == 134 ]]; then + xcodebuild "${build_args[@]}" + elif [[ "$build_status" != 0 ]]; then + exit "$build_status" + fi bundle="$RUNNER_TEMP/ipad-build/Build/Products/Debug-iphonesimulator/Ada Studio.app" xcrun simctl install "$device" "$bundle" - xcrun simctl launch --terminate-running-process "$device" org.adaengine.editor - sleep 12 + console="$evidence/ipad-console.log" + SIMCTL_CHILD_ADAENGINE_SAGA_DEBUG=1 SIMCTL_CHILD_NSUnbufferedIO=YES \ + xcrun simctl launch --console --terminate-running-process "$device" org.adaengine.editor >"$console" 2>&1 & + launch_pid=$! + trap 'kill "$launch_pid" 2>/dev/null || true' EXIT + for attempt in {1..90}; do + if grep -Fq "[SAGA] pipeline 'Upscale Pipeline'" "$console"; then break; fi + if ! kill -0 "$launch_pid" 2>/dev/null; then break; fi + sleep 2 + done + grep -F "[SAGA] pipeline 'Upscale Pipeline'" "$console" + sleep 2 xcrun simctl io "$device" screenshot "$evidence/ipad-studio.png" xcrun simctl spawn "$device" launchctl list | grep org.adaengine.editor xcrun simctl spawn "$device" log show --last 2m --style compact \ --predicate 'process == "AdaEditor"' > "$evidence/ipad-runtime.log" xcrun simctl terminate "$device" org.adaengine.editor ;; + studio-web) + python3 ada_tool.py prepare wasm + version="$(python3 -c 'import json; print(json.load(open("Tools/ada-toolchain.json"))["wasm"]["version"])')" + sdk="$(python3 -c 'import json; print(json.load(open("Tools/ada-toolchain.json"))["wasm"]["sdk"])')" + swift="$(swiftly run "+$version" swift -print-target-info | python3 -c \ + 'import json, pathlib, sys; print(pathlib.Path(json.load(sys.stdin)["paths"]["runtimeResourcePath"]).parents[1] / "bin/swift")')" + export PATH="${swift%/*}:$PATH" + build="$RUNNER_TEMP/studio-web-build" + python3 ada_tool.py build studio --debug --scratch-path "$build" + cli="$build/Build/Products/Debug/Ada Studio.app/Contents/MacOS/adastudio" + project="$RUNNER_TEMP/Shader Graph Smoke" + ditto Editor/Tests/Fixtures/ShaderGraphSmoke "$project" + output="$RUNNER_TEMP/studio-web-export" + "$cli" export --target web --project "$project" --output "$output" \ + --scratch-path "$RUNNER_TEMP/studio-web-swiftpm" --swift "$swift" --swift-sdk "$sdk" --format json > "$evidence/export.json" + playwright="$RUNNER_TEMP/studio-web-playwright" + npm install --prefix "$playwright" --no-audit --no-fund playwright-core@1.58.2 + "$playwright/node_modules/.bin/playwright-core" install chromium + SAGA_PLAYWRIGHT="$playwright/node_modules/playwright-core" \ + node script/web_golden_saga.mjs "$output/web" --studio-export --actual="$evidence/studio-web.png" + ;; *) echo "Unknown Apple validation mode: $check" >&2; exit 2 ;; esac diff --git a/script/shader_graph_validation_package.py b/script/shader_graph_validation_package.py index fa5e564bc..16c36949b 100644 --- a/script/shader_graph_validation_package.py +++ b/script/shader_graph_validation_package.py @@ -26,12 +26,12 @@ "Tests/AdaShaderGraphBakerTests/WebExportSupportTests.swift", ]: source = root / relative - shutil.copy2(source, tests / source.name) + content = source.read_text(encoding="utf-8") + content = content.replace("@testable import AdaShaderGraphBaker", "@testable import ShaderGraphValidationBaker") + (tests / source.name).write_text(content, encoding="utf-8") shutil.copytree(root / "Tests/AdaScriptAOTFixture", destination / "Tests/AdaScriptAOTFixture", dirs_exist_ok=True) shutil.copytree(root / "Plugins/AdaShaderGraphBaker", destination / "Sources/AdaShaderGraphBaker", dirs_exist_ok=True) shutil.copy2(root / "Plugins/AdaWebExportPlugin/WebExportSupport.swift", destination / "Sources/AdaShaderGraphBaker/WebExportSupport.swift") -shutil.copytree(root / "Tests/AdaShaderGraphBakerTests/Fixtures/FailingShaderCompiler", - destination / "Fixtures/FailingShaderCompiler", dirs_exist_ok=True) modules = ["AdaApp", "AdaCorePipelines", "AdaECS", "AdaRender", "AdaScene", "AdaScriptCompilerCore", "AdaScripting", "AdaSprite", "AdaUtils", "AdaTransform", "Math"] dependencies = ",\n".join('.product(name: ' + json.dumps(name) + ', package: "AdaEngine")' for name in modules) excluded = sorted(file.name for file in (root / "Tests/AdaScriptAOTFixture").glob("*.ada")) @@ -41,15 +41,15 @@ .package(name: "AdaEngine", path: ''' + json.dumps(str(root)) + '''), .package(name: "gravity-lang", path: ''' + json.dumps(str(root / "Sources/gravity-lang")) + ''') ], targets: [ - .executableTarget(name: "FailingShaderCompiler", path: "Fixtures/FailingShaderCompiler", - linkerSettings: [.linkedLibrary("swiftCore", .when(platforms: [.windows]))]), - .executableTarget(name: "AdaShaderGraphBaker", dependencies: [ + .executableTarget(name: "ShaderGraphValidationBaker", dependencies: [ + .product(name: "AdaWGSLCompiler", package: "AdaEngine"), .product(name: "AdaRender", package: "AdaEngine"), .product(name: "AdaCorePipelines", package: "AdaEngine"), - .product(name: "AdaScene", package: "AdaEngine"), .product(name: "AdaSprite", package: "AdaEngine")]), + .product(name: "AdaScene", package: "AdaEngine"), .product(name: "AdaSprite", package: "AdaEngine")], + path: "Sources/AdaShaderGraphBaker"), .target(name: "AdaScriptAOTFixture", dependencies: [.product(name: "CGravity", package: "gravity-lang")], path: "Tests/AdaScriptAOTFixture", exclude: ''' + json.dumps(excluded) + ''', publicHeadersPath: "include"), .testTarget(name: "ShaderGraphValidationTests", dependencies: [ -''' + dependencies + ''', "AdaShaderGraphBaker", "AdaScriptAOTFixture", .product(name: "GravityAOT", package: "gravity-lang")]) +''' + dependencies + ''', "ShaderGraphValidationBaker", "AdaScriptAOTFixture", .product(name: "GravityAOT", package: "gravity-lang")]) ]) ''') print(destination) diff --git a/script/test_ada_tool.py b/script/test_ada_tool.py index a409a340a..8e60457a2 100644 --- a/script/test_ada_tool.py +++ b/script/test_ada_tool.py @@ -403,7 +403,9 @@ def test_install_debug_bundle_in_exact_destination(self): source = root / "Source Кириллица & spaces.app" destination = root / "Applications/Ada Studio Debug.app" (source / "Contents/MacOS").mkdir(parents=True) - shutil.copy2("/bin/echo", source / "Contents/MacOS/Ada Studio") + executable = source / "Contents/MacOS/Ada Studio" + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') + executable.chmod(0o755) (source / "Contents/Info.plist").write_bytes(plistlib.dumps({"CFBundleIdentifier": "org.adaengine.editor", "CFBundleExecutable": "Ada Studio"})) result = subprocess.run([sys.executable, str(ROOT / "ada_tool.py"), "install", "studio", "--debug", "--source", str(source), diff --git a/script/test_build_studio_windows.py b/script/test_build_studio_windows.py index a585c2aed..cf65af0ea 100644 --- a/script/test_build_studio_windows.py +++ b/script/test_build_studio_windows.py @@ -42,7 +42,7 @@ def setUp(self): # Dependency setup is unrelated to logging. The production build script still # invokes the real SwiftPM/compiler on a real package, including its stderr. for name in ["ensure_windows_lore.py", "ensure_windows_icon.py", - "ensure_windows_export_tools.py", "ensure_tint.py"]: + "ensure_windows_export_tools.py"]: (self.scripts / name).write_text( 'import os\nprint(os.environ["ADA_STUDIO_LOGGING_TEST_SDK"])\n', encoding="utf-8") self.environment = os.environ.copy() diff --git a/script/test_install_studio_macos.py b/script/test_install_studio_macos.py index 6ce51f3cf..50d35b494 100644 --- a/script/test_install_studio_macos.py +++ b/script/test_install_studio_macos.py @@ -1,7 +1,6 @@ """Exercise app-bundle installation on macOS without touching /Applications.""" from pathlib import Path import plistlib -import shutil import subprocess import sys import tempfile @@ -20,7 +19,9 @@ def setUp(self): self.applications = self.root / "Applications" self.destination = self.applications / "Ada Studio.app" (self.source / "Contents/MacOS").mkdir(parents=True) - shutil.copy2("/bin/echo", self.source / "Contents/MacOS/Ada Studio") + executable = self.source / "Contents/MacOS/Ada Studio" + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') + executable.chmod(0o755) (self.source / "Contents/Info.plist").write_bytes(plistlib.dumps({ "CFBundleIdentifier": "org.adaengine.editor", "CFBundleExecutable": "Ada Studio", "CFBundlePackageType": "APPL", diff --git a/script/test_package_studio_windows.py b/script/test_package_studio_windows.py index 9b4c55d0f..ce68329da 100644 --- a/script/test_package_studio_windows.py +++ b/script/test_package_studio_windows.py @@ -22,13 +22,13 @@ def test_standalone_staging_preserves_an_existing_destination(self): marker = installed / "user-file.txt" marker.write_text("keep", encoding="utf-8") with self.assertRaises(FileExistsError): - packaging.package(Path("missing"), Path("missing"), Path("missing"), output, "local", stage_only=True) + packaging.package(Path("missing"), Path("missing"), output, "local", stage_only=True) self.assertEqual(marker.read_text(encoding="utf-8"), "keep") def test_version_cannot_escape_output_directory(self): for version in ["../escape", "v1/a", "v1\\a", "", "a" * 81]: with self.subTest(version=version), self.assertRaises(ValueError): - packaging.package(Path("missing"), Path("missing"), Path("missing"), Path("missing"), version) + packaging.package(Path("missing"), Path("missing"), Path("missing"), version) @unittest.skipUnless(os.name == "nt", "PE imports and runtime loading require Windows") def test_pe_import_closure_runs_after_relocation_without_system_dll_copies(self): diff --git a/script/test_wesl_backend_artifacts.py b/script/test_wesl_backend_artifacts.py new file mode 100644 index 000000000..dc3ec7a69 --- /dev/null +++ b/script/test_wesl_backend_artifacts.py @@ -0,0 +1,85 @@ +"""Exercise WESL backend emission through the production host tool.""" + +import os +import struct +import subprocess +import tempfile +import unittest +from pathlib import Path + + +class WESLBackendArtifactTests(unittest.TestCase): + def compile(self, root, target, *arguments): + return subprocess.run( + [os.environ["ADAENGINE_TEST_WESL_TRANSPILER"], "--input", str(root / "main.wesl"), + "--output-directory", str(root / "output"), "--target", target, *arguments], + capture_output=True, text=True, check=False, + ) + + def test_msl_emits_each_selected_stage_from_one_module(self): + with tempfile.TemporaryDirectory(prefix="ada-wesl-backend-") as temporary: + root = Path(temporary) + (root / "main.wesl").write_text( + "@vertex fn vertexMain() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); }" + "@fragment fn fragmentMain() -> @location(0) vec4f { return vec4f(1); }", encoding="utf-8", + ) + result = self.compile(root, "msl") + self.assertEqual(result.returncode, 0, result.stderr) + vertex = (root / "output/main.vert.msl").read_text(encoding="utf-8") + fragment = (root / "output/main.frag.msl").read_text(encoding="utf-8") + self.assertIn("vertex", vertex) + self.assertIn("fragment", fragment) + self.assertNotIn("gl_Position", vertex) + + def test_spirv_is_binary_and_preserves_feature_variant_identity(self): + with tempfile.TemporaryDirectory(prefix="ada-wesl-backend-") as temporary: + root = Path(temporary) + (root / "main.wesl").write_text( + "@if(bright) const value = 1.0; @else const value = 0.0;" + "@fragment fn fragmentMain() -> @location(0) vec4f { return vec4f(value); }", encoding="utf-8", + ) + for arguments in [(), ("--feature", "bright")]: + result = self.compile(root, "spirv", *arguments) + self.assertEqual(result.returncode, 0, result.stderr) + plain = (root / "output/main.frag.spv").read_bytes() + bright = (root / "output/main.bright.frag.spv").read_bytes() + self.assertEqual(struct.unpack(" result: array;" + "@compute @workgroup_size(1) fn computeMain() { result[0] = 42u; }", encoding="utf-8", + ) + for target, suffix in [("msl", "msl"), ("spirv", "spv")]: + result = self.compile(root, target) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((root / f"output/main.comp.{suffix}").stat().st_size > 0) + + def test_native_output_rejects_modules_without_entry_points(self): + with tempfile.TemporaryDirectory(prefix="ada-wesl-backend-") as temporary: + root = Path(temporary) + (root / "main.wesl").write_text("fn helper() -> f32 { return 1.0; }", encoding="utf-8") + for target in ["msl", "spirv"]: + result = self.compile(root, target) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Native WESL output requires at least one entry point", result.stderr) + self.assertFalse((root / "output").exists()) + + def test_web_stage_naming_cannot_be_combined_with_native_output(self): + with tempfile.TemporaryDirectory(prefix="ada-wesl-backend-") as temporary: + root = Path(temporary) + (root / "main.wesl").write_text( + "@fragment fn fragmentMain() -> @location(0) vec4f { return vec4f(1); }", encoding="utf-8", + ) + result = self.compile(root, "msl", "--web-stages") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--web-stages requires WGSL output", result.stderr) + self.assertFalse((root / "output").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/script/test_wesl_transpiler.py b/script/test_wesl_transpiler.py new file mode 100644 index 000000000..1e2cabf64 --- /dev/null +++ b/script/test_wesl_transpiler.py @@ -0,0 +1,119 @@ +"""Run the host transpiler on real WESL files without an external linker.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class WESLTranspilerTests(unittest.TestCase): + def compile(self, root, *arguments): + tool = os.environ["ADAENGINE_TEST_WESL_TRANSPILER"] + return subprocess.run( + [tool, "--input", str(root / "main.wesl"), "--output-directory", str(root), *arguments], + env={**os.environ, "ADAENGINE_WESL_NODE_PATH": str(root / "missing-node")}, + capture_output=True, text=True, check=False, + ) + + def test_links_import_and_builds_both_variants_inside_the_engine(self): + with tempfile.TemporaryDirectory(prefix="ada wesl ") as temporary: + root = Path(temporary) + (root / "common.wesl").write_text("fn shade() -> vec4f { return vec4f(1); }", encoding="utf-8") + main = root / "main.wesl" + main.write_text( + """ + import package::common::shade as baseColor; + @if(textured) @group(0) @binding(0) var image: texture_2d; + @fragment fn fragmentMain() -> @location(0) vec4f { + @if(textured) + return textureLoad(image, vec2i(0), 0) * baseColor(); + @else + return baseColor(); + } + """, + encoding="utf-8", + ) + for enabled in (False, True): + arguments = ["--feature", "textured"] if enabled else [] + result = self.compile(root, *arguments) + self.assertEqual(result.returncode, 0, f"In-process WESL linking failed: {result.stderr}") + output = (root / ("main.textured.wgsl" if enabled else "main.wgsl")).read_text(encoding="utf-8") + self.assertIn("fragmentMain", output) + self.assertEqual("texture_2d" in output, enabled) + + def test_links_named_dependency_with_package_relative_imports(self): + with tempfile.TemporaryDirectory(prefix="ada wesl ") as temporary: + root = Path(temporary) + shared = root / "shared" + shared.mkdir() + (shared / "color.wgsl").write_text("const tint = vec4f(1);", encoding="utf-8") + (shared / "surface.wesl").write_text( + "import package::color::tint; fn shade() -> vec4f { return tint; }", encoding="utf-8" + ) + (root / "main.wesl").write_text( + "import engine::surface::shade; @fragment fn fragmentMain() -> @location(0) vec4f { return shade(); }", + encoding="utf-8", + ) + result = self.compile(root, "--wesl-dependency", f"engine={shared}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("ada_wesl_6_engine_5_color_tint", (root / "main.wgsl").read_text(encoding="utf-8")) + + def test_web_stages_keep_named_variant_sidecars_separate(self): + with tempfile.TemporaryDirectory(prefix="ada wesl ") as temporary: + root = Path(temporary) + (root / "main.wesl").write_text( + "@if(morph) const morphOffset = 1.0; @else const morphOffset = 0.0;" + "@vertex fn vertexMain() -> @builtin(position) vec4f { return vec4f(morphOffset); }", + encoding="utf-8", + ) + for arguments in [("--web-stages",), ("--web-stages", "--feature", "morph")]: + result = self.compile(root, *arguments) + self.assertEqual(result.returncode, 0, result.stderr) + plain = (root / "main.wesl.vert.wgsl").read_text(encoding="utf-8") + variant = (root / "main.wesl.morph.vert.wgsl").read_text(encoding="utf-8") + self.assertIn("morphOffset = 0.0", plain) + self.assertIn("morphOffset = 1.0", variant) + + def test_inactive_template_arguments_and_imports_are_removed_as_units(self): + with tempfile.TemporaryDirectory(prefix="ada wesl ") as temporary: + root = Path(temporary) + (root / "main.wesl").write_text( + """ + @if(disabled) import package::absent::helper; + @if(disabled) alias Unused = array; + @if(disabled) @group(0) @binding(0) var data: array; + struct Payload { @if(disabled) values: array, color: vec4f, } + @fragment fn fragmentMain() -> @location(0) vec4f { return Payload(vec4f(1)).color; } + """, + encoding="utf-8", + ) + result = self.compile(root) + self.assertEqual(result.returncode, 0, f"Conditional template was not removed: {result.stderr}") + output = (root / "main.wgsl").read_text(encoding="utf-8") + self.assertNotIn("storage", output) + self.assertNotIn("values", output) + + def test_direct_qualified_reference_does_not_capture_root_globals(self): + with tempfile.TemporaryDirectory(prefix="ada wesl ") as temporary: + root = Path(temporary) + helper = root / "common.wesl" + helper.write_text("fn shade() -> vec4f { return vec4f(1); }", encoding="utf-8") + (root / "main.wesl").write_text( + """ + const hidden = vec4f(1); + @fragment fn fragmentMain() -> @location(0) vec4f { + return package::common::shade(); + } + """, + encoding="utf-8", + ) + self.assertEqual(self.compile(root).returncode, 0) + helper.write_text("fn shade() -> vec4f { return hidden; }", encoding="utf-8") + result = self.compile(root) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not imported", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/script/validate_webgpu.sh b/script/validate_webgpu.sh index 5ad4d73b1..772407b3a 100755 --- a/script/validate_webgpu.sh +++ b/script/validate_webgpu.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Always exercise Tint; do not silently accept cached WGSL or skip a missing tool. +# Compile fresh shaders through SAGACompiler. set -euo pipefail ada_validation_root="$(cd "$(dirname "$0")/.." && pwd)" cd "$ada_validation_root" @@ -8,9 +8,7 @@ if [[ "${OS:-}" == "Windows_NT" ]]; then else ada_validation_python="${ADAENGINE_PYTHON:-python3}" fi -export TINT_EXECUTABLE -TINT_EXECUTABLE="$("$ada_validation_python" script/ensure_tint.py)" -export ADAENGINE_WEB_EXPORT=1 ADAENGINE_VALIDATE_TINT=1 ADAENGINE_SPRITE_WGPU_SMOKE=1 ADAENGINE_GOLDEN_WEBGPU=1 +export ADAENGINE_WEB_EXPORT=1 ADAENGINE_VALIDATE_WGSL=1 ADAENGINE_SPRITE_WGPU_SMOKE=1 ADAENGINE_GOLDEN_WEBGPU=1 export ADAENGINE_WEBGPU_VISIBILITY_SMOKE=1 ADAENGINE_WEBGPU_SCENE_SMOKE=1 export ADAENGINE_SHADER_SOURCE_ROOT="$ada_validation_root" ada_validation_scratch="${ADAENGINE_WEBGPU_SCRATCH:-/tmp/adaengine-webgpu-dev-validation}" @@ -31,7 +29,7 @@ if [[ "${ADAENGINE_VALIDATE_SPONZA:-}" == "1" ]]; then fi ada_validation_package="$("$ada_validation_python" script/webgpu_validation_package.py "$ada_validation_scratch/validation-package" "${ada_validation_package_args[@]}")" # The GPU suites share one process-wide renderer, so the validation package runs its tests one at a time. -ada_validation_filter='PluginLoadingTests|TintToolchainTests|TintShaderCompilerTests|WGSLReflectionTests|ShaderGraph|SpriteGraphs|SpriteMetalRenderingTests/rendersLayoutsAndPickingWithWebGPU|SpriteMetalRenderingTests/rendersTileOcclusionWithWebGPU|BoundedResourceCacheTests|RenderGraphDiagnosticsTests|WindowFrameRequestsTests|WGPUBindingStateTests|WGPUCommandCompletionTests|WGPUVisibilityProcessorTests|MaterialUploadReuseTests|OptionalResourceTests|ShaderIncluderTests|ShaderCacheKeyTests|RenderUpscalingTests|Render3D|GoldenFrameComparisonTests|GoldenBackendAgreementTests|VisibleMesh3DListsEquivalenceTests' +ada_validation_filter='PluginLoadingTests|TintToolchainTests|WGSLShaderCompilerTests|WESLShaderModuleTests|WESLGradientPipelineTests|WESLUIPipelineTests|WGSLReflectionTests|ShaderGraph|SpriteGraphs|SpriteMetalRenderingTests/rendersLayoutsAndPickingWithWebGPU|SpriteMetalRenderingTests/rendersTileOcclusionWithWebGPU|BoundedResourceCacheTests|RenderGraphDiagnosticsTests|WindowFrameRequestsTests|WGPUBindingStateTests|WGPUCommandCompletionTests|WGPUVisibilityProcessorTests|MaterialUploadReuseTests|OptionalResourceTests|ShaderIncluderTests|ShaderCacheKeyTests|RenderUpscalingTests|Render3D|GoldenFrameComparisonTests|GoldenBackendAgreementTests|VisibleMesh3DListsEquivalenceTests' ada_validation_filter="$ada_validation_filter|Particle3DTests" ada_validation_filter="$ada_validation_filter|ColorCurves3DTests" ada_validation_filter="${ADAENGINE_WEBGPU_TEST_FILTER:-$ada_validation_filter}" diff --git a/script/web_golden_saga.mjs b/script/web_golden_saga.mjs new file mode 100644 index 000000000..f1bf29378 --- /dev/null +++ b/script/web_golden_saga.mjs @@ -0,0 +1,141 @@ +// Golden-frame check for the SAGA browser renderer. +// +// node script/web_golden_saga.mjs [--record] +// node script/web_golden_saga.mjs --studio-export [--actual=] +// +// is an `AdaWebPlayer` export packed with Tests/WebGolden/SAGASmoke (see the README next to it). +// The bundle must contain `saga-golden.mjs` and `saga-webgpu.mjs` (shipped by the export plugin). +// With --record the settled frame replaces the golden PNG; otherwise it is compared with it and the exit code is +// 1 on mismatch. +// +// Needs `playwright-core` (or SAGA_PLAYWRIGHT=) and a Chromium with WebGPU (SAGA_CHROMIUM=). +import assert from "node:assert/strict"; +import { createReadStream } from "node:fs"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { dirname, extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +const record = args.includes("--record"); +const studioExport = args.includes("--studio-export"); +if (record && studioExport) throw new Error("The Studio export smoke has no golden to record"); +const bundle = args.find(arg => !arg.startsWith("--")); +if (!bundle) throw new Error("Pass the exported web player bundle directory"); +const actualPath = args.find(arg => arg.startsWith("--actual="))?.slice("--actual=".length); + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const goldenPath = join(root, "Tests/AdaEngineTests/GoldenFrames/saga-browser/smoke-160x96.png"); +const types = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".wasm": "application/wasm", + ".json": "application/json", ".png": "image/png", ".wgsl": "text/plain", ".glb": "model/gltf-binary" }; + +const server = createServer(async (request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + let path = normalize(join(resolve(bundle), decodeURIComponent(url.pathname))); + if (url.pathname === "/golden/smoke-160x96.png" && !record) path = goldenPath; + if (!path.startsWith(resolve(bundle)) && path !== goldenPath) { response.writeHead(403).end(); return; } + try { + if (!(await stat(path)).isFile()) throw new Error("not a file"); + response.writeHead(200, { "content-type": types[extname(path)] ?? "application/octet-stream", "cache-control": "no-store" }); + createReadStream(path).pipe(response); + } catch { + response.writeHead(404).end(); + } +}); +await new Promise(done => server.listen(0, "127.0.0.1", done)); +const origin = `http://127.0.0.1:${server.address().port}`; + +const require = createRequire(import.meta.url); +const { chromium } = require(process.env.SAGA_PLAYWRIGHT ?? "playwright-core"); +const browser = await chromium.launch({ + executablePath: process.env.SAGA_CHROMIUM, + args: ["--enable-unsafe-webgpu", "--enable-features=Vulkan", ...(process.platform === "darwin" ? ["--use-angle=metal"] : [])], +}); +let failed = false; +try { + // Preserve the projection of the original smoke reference before downsampling. + const viewport = studioExport ? { width: 1024, height: 700 } : { width: 1369, height: 829 }; + const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }); + const pageErrors = []; + page.on("console", message => { if (message.type() === "error") console.error("[page]", message.text()); }); + page.on("pageerror", error => { + pageErrors.push(error.message); + console.error("[page]", error.stack); + }); + await page.goto(`${origin}/index.html?solo=1`); + const startup = await page.waitForFunction(() => { + const loader = document.querySelector("#ada-loader[data-state='error']"); + if (loader) return { error: loader.innerText }; + return globalThis.__adaSAGAHost?.metrics?.().presentedFrames > 0; + }, null, { timeout: 120_000 }).catch(async error => { + const state = await page.evaluate(() => ({ + loader: document.querySelector("#ada-loader")?.innerText, + metrics: globalThis.__adaSAGAHost?.metrics?.(), + })); + throw new Error(`No SAGA frame was presented: ${JSON.stringify(state)}`, { cause: error }); + }); + const startupResult = await startup.jsonValue(); + if (startupResult.error) throw new Error(startupResult.error); + if (studioExport) { + const initial = await captureStudioSprite(page); + await page.setViewportSize({ width: 760, height: 540 }); + await page.waitForFunction(previous => { + const metrics = globalThis.__adaSAGAHost?.metrics?.(); + return metrics?.surfaceSize + && (metrics.surfaceSize[0] !== previous.width || metrics.surfaceSize[1] !== previous.height) + && metrics.presentedFrames > previous.frames; + }, initial, { timeout: 30_000 }); + const resized = await captureStudioSprite(page); + assert.deepEqual(pageErrors, []); + if (actualPath) await page.screenshot({ path: actualPath }); + console.log(JSON.stringify({ initial, resized })); + } else if (record) { + const base64 = await page.evaluate(async () => { + const golden = await import("./saga-golden.mjs"); + const frame = await golden.captureStable(globalThis.__adaSAGAHost); + const bytes = new Uint8Array(await (await golden.frameToPng(frame)).arrayBuffer()); + let text = ""; + for (const byte of bytes) text += String.fromCharCode(byte); + return btoa(text); + }); + await mkdir(dirname(goldenPath), { recursive: true }); + await writeFile(goldenPath, Buffer.from(base64, "base64")); + console.log(`Recorded ${goldenPath}`); + } else { + const { actualPNG, ...result } = await page.evaluate(async (saveActual) => { + const golden = await import("./saga-golden.mjs"); + const { actual, ...rest } = await golden.checkGolden(globalThis.__adaSAGAHost, "./golden/smoke-160x96.png"); + if (!saveActual) return rest; + const blob = await golden.frameToPng(actual); + return { ...rest, actualPNG: Array.from(new Uint8Array(await blob.arrayBuffer())) }; + }, Boolean(actualPath)); + if (actualPath) await writeFile(actualPath, Buffer.from(actualPNG)); + console.log(JSON.stringify(result)); + failed = !result.ok; + } +} finally { + await browser.close(); + server.close(); +} +process.exit(failed ? 1 : 0); + +async function captureStudioSprite(page) { + const result = await page.evaluate(async () => { + const host = globalThis.__adaSAGAHost; + const { captureStable } = await import("./saga-golden.mjs"); + const frame = await captureStable(host, { width: 320, height: 200 }); + let spritePixels = 0; + for (let index = 0; index < frame.pixels.length; index += 4) { + const red = frame.pixels[index]; + const green = frame.pixels[index + 1]; + const blue = frame.pixels[index + 2]; + if (red > 155 && red > green + 40 && red > blue + 40) spritePixels++; + } + const { surfaceSize, presentedFrames } = host.metrics(); + return { width: surfaceSize?.[0], height: surfaceSize?.[1], frames: presentedFrames, spritePixels }; + }); + assert.ok(result.width > 0 && result.height > 0, "SAGA did not report a drawable surface"); + assert.ok(result.spritePixels > 25, "No orange Shader Graph sprite in the presented frame"); + return result; +} diff --git a/script/webgpu_validation_package.py b/script/webgpu_validation_package.py index 9726cb3f9..1e65278e5 100644 --- a/script/webgpu_validation_package.py +++ b/script/webgpu_validation_package.py @@ -25,7 +25,10 @@ (sources / "SponzaCameraTests.swift").unlink(missing_ok=True) for relative in [ "Tests/AdaUtilsTests/TintToolchainTests.swift", - "Tests/AdaRenderTests/TintShaderCompilerTests.swift", + "Tests/AdaRenderTests/WGSLShaderCompilerTests.swift", + "Tests/AdaRenderTests/WESLShaderModuleTests.swift", + "Tests/AdaEngineTests/WESLGradientPipelineTests.swift", + "Tests/AdaEngineTests/WESLUIPipelineTests.swift", "Tests/AdaRenderTests/WGSLReflectionTests.swift", "Tests/AdaRenderTests/ShaderGraphTests.swift", "Tests/AdaRenderTests/ShaderGraphProgramTests.swift",